5757// arm it — yet `os validate` never builds the engine, so until this rule
5858// it answered "passed" for a flow no runtime will ever register. It is its
5959// OWN exported rule, `validateFlowApiTriggerSecret` (1h, at the foot of
60- // this file), because it runs on the CLI surface only until #20611 — see
61- // its docblock for why, and see `FLOW_API_TRIGGER_SECRET_MISSING` for why
62- // the judgement is carried here rather than read from the runtime.
60+ // this file), because at the runtime publish gate it reads one input the
61+ // rest of the family does not (#20611) — see its docblock for why, and see
62+ // `FLOW_API_TRIGGER_SECRET_MISSING` for why the judgement is carried here
63+ // rather than read from the runtime.
6364//
6465// ⚠️ One more rule lived here and is RETIRED (#17396):
6566// `flow-schedule-organization-missing`, a `warning` on a time-triggered
140141// flows keep being served. What IS refused is the dead flow's own publish — and,
141142// on the CLI surface, a package build whose stack contains one.
142143//
143- // ⚠️ All of the above is about `validateFlowTriggerReadiness`. The sixth id,
144- // `flow-api-trigger-secret-missing`, lives in `validateFlowApiTriggerSecret` on
145- // its own registry entry, which is CLI-only until #20611: the publish gate
146- // judges a `/meta` save before the stored `config.secret` the read path withheld
147- // is restored, so at that door a signed flow's round trip would read as
148- // secretless. That door still stores a secretless flow today, and the engine
149- // refuses it at registration.
144+ // The sixth id, `flow-api-trigger-secret-missing`, lives in
145+ // `validateFlowApiTriggerSecret` on its own registry entry, which crosses the
146+ // runtime wall too (#20611) on the same per-write snapshot. It reads one more
147+ // input there: the publish gate judges a `/meta` save before the stored
148+ // `config.secret` the read path withheld is restored, so the gate hands the rule
149+ // the positions that restore fills, and a withheld-and-stored secret reads as
150+ // present. A secretless `api` flow is refused at that door; a signed flow's
151+ // round trip is not.
150152
151153import {
152154 TimeRelativeTriggerSchema ,
@@ -773,8 +775,9 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
773775
774776 // 1h. ⚠️ NOT here — the `api` trigger's secret (#20553) is its own exported
775777 // rule, {@link validateFlowApiTriggerSecret} below, on its own registry
776- // entry: it sits on the OTHER side of the runtime wall (CLI-only until
777- // #20611), and one rule id sits on ONE side of it.
778+ // entry: at the runtime publish gate it reads the host's
779+ // restored-credential positions (#20611), an input nothing else in this
780+ // family has a use for.
778781
779782 // 2. Auto-triggered flow whose status is 'draft' — authored or defaulted
780783 // (defineFlow parses at definition time, so the two are the same here).
@@ -822,32 +825,45 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
822825 * engine reads its `config` as `{}` and refuses it for the same reason — and is
823826 * located at `nodes`, since there is no start node to point at.
824827 *
825- * ## Why this is a separate function from { @link validateFlowTriggerReadiness}
828+ * ## At the runtime publish gate: a withheld-and-stored secret is present (#20611)
826829 *
827- * A surface boundary, not taste — the registry's `validateSecurityRoleWord`
828- * split is the precedent. `validateFlowTriggerReadiness` runs on the runtime
829- * publish gate too; this rule cannot, yet (#20611). The flow read path withholds
830- * `config.secret` from every served definition (#20552), and `saveMetaItem`
831- * restores the stored secret only just before the put — AFTER the runtime
832- * authoring gate has judged the body the caller sent. So an ordinary `/meta`
833- * GET → edit → PUT of a SIGNED flow reaches the gate secretless, and this rule
834- * would refuse a save that keeps the secret. Measured on `825c33ff9f`: with this
835- * id at the gate, `protocol.metadata-redaction.test.ts` fails exactly its two
836- * round-trip pins; with it dropped there, 26/26 pass. So this id stays CLI-only
837- * (`os validate` / `os build` / `os lint`, whose stacks carry the author's own
838- * secret) until the gate judges the carried-forward body, and it is split out
839- * WHOLE rather than filtered at one entry: one rule id sits on ONE side of the
840- * wall. Meanwhile the `/meta` door behaves as it did before this rule existed:
841- * it stores a secretless flow, and the engine refuses it at registration.
830+ * The flow read path withholds `config.secret` from every served definition
831+ * (#20552), and `saveMetaItem` restores the stored secret only just before the
832+ * put — AFTER the runtime authoring gate has judged the body the caller sent,
833+ * and deliberately so: no gate handles a restored credential. So an ordinary
834+ * `/meta` GET → edit → PUT of a SIGNED flow reaches this rule without its
835+ * secret, exactly like a flow that never had one. The body alone cannot tell
836+ * the two apart; the host can, and the gate passes its answer in as
837+ * `options.restoredCredentialPaths` (`AuthoringRuleContext`): the positions the
838+ * host's carry-forward will fill from the stored row. The start node's secret
839+ * path listed there is WITHHELD AND STORED, and reads as present; one not
840+ * listed is judged on the body as sent, so a secret that is absent and not
841+ * stored is missing, and the save is refused. The CLI never sets the option —
842+ * its stacks carry the author's own secret.
843+ *
844+ * It is a separate function from {@link validateFlowTriggerReadiness} because
845+ * that option is its input alone: the rest of the family judges nothing the
846+ * read path withholds.
847+ *
848+ * @param options.restoredCredentialPaths Stack-relative paths, in this rule's
849+ * own finding-path spelling (`flows[0].nodes[1].config.secret`), that the
850+ * write path restores from the stored row. Positions only, never values.
842851 */
843- export function validateFlowApiTriggerSecret ( stack : AnyRec ) : FlowTriggerReadinessFinding [ ] {
852+ export function validateFlowApiTriggerSecret (
853+ stack : AnyRec ,
854+ options : { restoredCredentialPaths ?: ReadonlySet < string > } = { } ,
855+ ) : FlowTriggerReadinessFinding [ ] {
844856 const findings : FlowTriggerReadinessFinding [ ] = [ ] ;
845857 recordsOf ( stack . flows ) . forEach ( ( flow , flowIndex ) => {
846858 const flowName = typeof flow . name === 'string' ? flow . name : `#${ flowIndex } ` ;
847859 const start = startNodeOf ( flow ) ;
848860 const config = ( start ?. node . config ?? { } ) as AnyRec ;
849861 const triggerType = typeof config . triggerType === 'string' ? config . triggerType : undefined ;
850862 const bindsApiTrigger = ! isArrayRecordTriggerType ( config ) && resolveFlowTriggerKind ( flow ) === 'api' ;
863+ const secretPath = start ? `flows[${ flowIndex } ].nodes[${ start . index } ].config.secret` : undefined ;
864+ // [#20611] Withheld and stored ⇒ present: the host restores the stored
865+ // secret at exactly this position before the item is persisted.
866+ if ( secretPath !== undefined && options . restoredCredentialPaths ?. has ( secretPath ) ) return ;
851867 const secretProblem = bindsApiTrigger ? describeUnusableSecret ( start , config ) : undefined ;
852868 if ( ! secretProblem ) return ;
853869 // Which declaration binds it — the engine's own message names the same
@@ -864,9 +880,7 @@ export function validateFlowApiTriggerSecret(stack: AnyRec): FlowTriggerReadines
864880 severity : 'error' ,
865881 rule : FLOW_API_TRIGGER_SECRET_MISSING ,
866882 where : start ? `flow "${ flowName } " › start node` : `flow "${ flowName } "` ,
867- path : start
868- ? `flows[${ flowIndex } ].nodes[${ start . index } ].config.secret`
869- : `flows[${ flowIndex } ].nodes` ,
883+ path : secretPath ?? `flows[${ flowIndex } ].nodes` ,
870884 message :
871885 `binds the inbound api trigger (${ binds . join ( ' and ' ) } ) but ${ secretProblem } . An inbound hook ` +
872886 `is armed only with a per-flow secret that every post is HMAC-verified against (ADR-0041), so the ` +
0 commit comments