22
33import { z } from 'zod' ;
44import { lazySchema } from '../shared/lazy-schema' ;
5+ import { strictUnknownKeyError } from '../shared/suggestions.zod' ;
56
67// Why the members sit in THIS order (the generated reference renders the JSDoc
78// below; this rationale stays in source):
@@ -319,6 +320,39 @@ export const APPROVAL_BRANCH_LABELS = {
319320 resubmit : 'resubmit' ,
320321} as const ;
321322
323+ /*
324+ * ── Unknown-key strictness (#4001 step 3) ───────────────────────────────────
325+ *
326+ * The four AUTHORING schemas in this module are `.strict()`: a key they do not
327+ * declare is a loud, fixable parse error, not a silent strip. Approval is a
328+ * v17-new authoring surface — tightened while young, before any stored volume
329+ * exists. Note the blast radius is wider than a spec parse: the published
330+ * JSON schema (see {@link getApprovalNodeConfigJsonSchema}) carries
331+ * `additionalProperties: false` into the Studio property form AND
332+ * `registerFlow()`'s per-node config validation (#4027/#4040), so an unknown
333+ * key inside an approval node's `config` is rejected at registration too —
334+ * deliberately: an approval gate that quietly ignores half its config is the
335+ * worst instance of the ADR-0078 trap.
336+ */
337+
338+ /** Keys {@link ApprovalNodeApproverSchema} declares (drift-guarded by approval.test.ts). */
339+ const APPROVAL_APPROVER_KEYS = [ 'type' , 'value' , 'resolveAs' , 'group' , 'organization' ] as const ;
340+
341+ const approvalApproverUnknownKeyError = strictUnknownKeyError ( {
342+ surface : 'this approval approver' ,
343+ knownKeys : APPROVAL_APPROVER_KEYS ,
344+ aliases : {
345+ approver : 'value' ,
346+ userid : 'value' ,
347+ org : 'organization' ,
348+ grouplabel : 'group' ,
349+ expandas : 'resolveAs' ,
350+ } ,
351+ history :
352+ 'Until #4001 these were dropped silently — the approver still parsed, so the ' +
353+ 'request could route to the wrong slate without a diagnostic.' ,
354+ } ) ;
355+
322356/** A single approver assignment on an Approval node. */
323357export const ApprovalNodeApproverSchema = lazySchema ( ( ) => z . object ( {
324358 // `xEnumDeprecated` lists enum members that still PARSE but must not be
@@ -433,7 +467,7 @@ export const ApprovalNodeApproverSchema = lazySchema(() => z.object({
433467 + '`$parent` (one level up), or an organization slug. Omitted = the request\'s own organization.' ,
434468 xRef : { kind : 'organization' , symbols : [ ...APPROVER_ORG_SYMBOLS ] } ,
435469 } ) ,
436- } ) ) ;
470+ } , { error : approvalApproverUnknownKeyError } ) . strict ( ) ) ;
437471export type ApprovalNodeApprover = z . infer < typeof ApprovalNodeApproverSchema > ;
438472
439473/**
@@ -443,6 +477,15 @@ export type ApprovalNodeApprover = z.infer<typeof ApprovalNodeApproverSchema>;
443477 * The runtime treats `key` as the whitelist entry either way — `type` and
444478 * `multiple` only shape the INPUT WIDGET, never the accepted value.
445479 */
480+ const decisionOutputUnknownKeyError = strictUnknownKeyError ( {
481+ surface : 'this decision-output declaration' ,
482+ knownKeys : [ 'key' , 'label' , 'type' , 'multiple' , 'required' ] ,
483+ aliases : { name : 'key' , widget : 'type' , many : 'multiple' } ,
484+ history :
485+ 'Until #4001 these were dropped silently — the declaration still parsed, so the ' +
486+ 'decision dialog rendered a different input than the author specified.' ,
487+ } ) ;
488+
446489export const DecisionOutputDefSchema = lazySchema ( ( ) => z . object ( {
447490 /** The output key — what the flow receives as `<nodeId>.<key>`. */
448491 key : z . string ( ) . min ( 1 ) . describe ( 'Output key (the flow variable name under the node id)' ) ,
@@ -473,7 +516,7 @@ export const DecisionOutputDefSchema = lazySchema(() => z.object({
473516 * filled them in.
474517 */
475518 required : z . boolean ( ) . optional ( ) . describe ( 'Approver must supply this output to approve' ) ,
476- } ) ) ;
519+ } , { error : decisionOutputUnknownKeyError } ) . strict ( ) ) ;
477520export type DecisionOutputDef = z . infer < typeof DecisionOutputDefSchema > ;
478521
479522/**
@@ -509,6 +552,21 @@ export function normalizeDecisionOutputs(
509552 * Per-node SLA escalation — carried on the Approval node itself, so each
510553 * Approval step on the canvas defines its own SLA.
511554 */
555+ const approvalEscalationUnknownKeyError = strictUnknownKeyError ( {
556+ surface : 'this approval escalation' ,
557+ knownKeys : [ 'enabled' , 'timeoutHours' , 'action' , 'escalateTo' , 'notifySubmitter' ] ,
558+ aliases : {
559+ timeout : 'timeoutHours' ,
560+ hours : 'timeoutHours' ,
561+ sla : 'timeoutHours' ,
562+ to : 'escalateTo' ,
563+ target : 'escalateTo' ,
564+ } ,
565+ history :
566+ 'Until #4001 these were dropped silently — the escalation still parsed, so an SLA ' +
567+ 'the author declared never fired the way they intended.' ,
568+ } ) ;
569+
512570export const ApprovalEscalationSchema = lazySchema ( ( ) => z . object ( {
513571 enabled : z . boolean ( ) . default ( false ) . describe ( 'Enable SLA-based escalation for this node' ) ,
514572 timeoutHours : z . number ( ) . min ( 1 ) . describe ( 'Hours before escalation triggers' ) ,
@@ -525,7 +583,7 @@ export const ApprovalEscalationSchema = lazySchema(() => z.object({
525583 xRef : { kind : 'position' } ,
526584 } ) ,
527585 notifySubmitter : z . boolean ( ) . default ( true ) . describe ( 'Notify the original submitter on escalation' ) ,
528- } ) ) ;
586+ } , { error : approvalEscalationUnknownKeyError } ) . strict ( ) ) ;
529587export type ApprovalEscalation = z . infer < typeof ApprovalEscalationSchema > ;
530588
531589/**
@@ -544,6 +602,49 @@ export type ApprovalEscalation = z.infer<typeof ApprovalEscalationSchema>;
544602 * first-class engine-adjacent state owned by `plugin-approvals`; this config
545603 * only describes how the node behaves.
546604 */
605+ /** Keys {@link ApprovalNodeConfigSchema} declares (drift-guarded by approval.test.ts). */
606+ const APPROVAL_NODE_CONFIG_KEYS = [
607+ 'approvers' , 'behavior' , 'minApprovals' , 'lockRecord' , 'approvalStatusField' ,
608+ 'onEmptyApprovers' , 'decisionOutputs' , 'escalation' , 'maxRevisions' ,
609+ ] as const ;
610+
611+ const approvalNodeConfigUnknownKeyError = strictUnknownKeyError ( {
612+ surface : "this approval node's config" ,
613+ knownKeys : APPROVAL_NODE_CONFIG_KEYS ,
614+ aliases : {
615+ approver : 'approvers' ,
616+ approvalmode : 'behavior' ,
617+ mode : 'behavior' ,
618+ statusfield : 'approvalStatusField' ,
619+ quorum : 'minApprovals' ,
620+ } ,
621+ guidance : {
622+ // The ADR-0019 re-home map: the process-level approval concepts an author
623+ // (or AI) trained on Salesforce-style approval processes reaches for, each
624+ // pointed at where the concept lives on the flow graph now.
625+ steps :
626+ '`steps` is not an approval-node config key — ADR-0019 collapsed the standalone ' +
627+ 'approval process into Flow: successive approval STEPS are successive `approval` ' +
628+ 'NODES on the canvas, each with its own config.' ,
629+ entryCriteria :
630+ '`entryCriteria` is not an approval-node config key — entry criteria are the ' +
631+ '`condition` on the EDGE entering this node (ADR-0019).' ,
632+ onApprove :
633+ '`onApprove` is not an approval-node config key — on-approve actions are the ' +
634+ "nodes wired to this node's `approve` out-edge (ADR-0019)." ,
635+ onReject :
636+ '`onReject` is not an approval-node config key — on-reject actions are the ' +
637+ "nodes wired to this node's `reject` out-edge (ADR-0019)." ,
638+ rejectionBehavior :
639+ '`rejectionBehavior` is not an approval-node config key — back-to-previous is a ' +
640+ 'declared BACK-EDGE to an earlier node (`type: \'back\'`, ADR-0044), and the ' +
641+ 'revise loop is the `revise` out-edge with `maxRevisions` bounding it.' ,
642+ } ,
643+ history :
644+ 'Until #4001 these were dropped silently — the node still parsed, so an approval ' +
645+ 'gate shipped that quietly ignored part of its declared behavior.' ,
646+ } ) ;
647+
547648export const ApprovalNodeConfigSchema = lazySchema ( ( ) => z . object ( {
548649 /** Who may act on this step. */
549650 approvers : z . array ( ApprovalNodeApproverSchema ) . min ( 1 ) . describe ( 'Allowed approvers for this node' ) ,
@@ -644,7 +745,7 @@ export const ApprovalNodeConfigSchema = lazySchema(() => z.object({
644745 */
645746 maxRevisions : z . number ( ) . int ( ) . min ( 0 ) . default ( 3 )
646747 . describe ( 'Max send-backs for revision before auto-reject (0 = send-back disabled)' ) ,
647- } ) ) ;
748+ } , { error : approvalNodeConfigUnknownKeyError } ) . strict ( ) ) ;
648749export type ApprovalNodeConfig = z . infer < typeof ApprovalNodeConfigSchema > ;
649750
650751/**
0 commit comments