@@ -48,6 +48,8 @@ interface SchemaNode {
4848 items ?: SchemaNode ;
4949 /** `true` = an open map with untyped values; an object = the schema every value takes. */
5050 additionalProperties ?: boolean | SchemaNode ;
51+ /** The keys of `properties` this object requires (JSON Schema `required`). */
52+ required ?: string [ ] ;
5153 xExpression ?: string ;
5254}
5355
@@ -83,9 +85,9 @@ const ROLE_BY_MARKER: Record<string, FlowNodeExpressionRole> = {
8385function collectExpressionProps (
8486 schema : SchemaNode | undefined ,
8587 prefix = '' ,
86- ) : { path : string ; marker : string } [ ] {
88+ ) : { path : string ; marker : string ; required : boolean } [ ] {
8789 if ( ! schema || typeof schema !== 'object' ) return [ ] ;
88- const out : { path : string ; marker : string } [ ] = [ ] ;
90+ const out : { path : string ; marker : string ; required : boolean } [ ] = [ ] ;
8991
9092 if ( schema . properties ) {
9193 for ( const [ key , prop ] of Object . entries ( schema . properties ) ) {
@@ -96,7 +98,11 @@ function collectExpressionProps(
9698 const here = prefix
9799 ? `${ prefix } .${ key } ${ isObjectArray ? '[]' : '' } `
98100 : `${ key } ${ isObjectArray ? '[]' : '' } ` ;
99- if ( typeof prop . xExpression === 'string' ) out . push ( { path : here , marker : prop . xExpression } ) ;
101+ // [#19961] Whether the declaring object REQUIRES the slot — read off the
102+ // same schema the marker is, so the ledger's `required` flag is
103+ // reconciled against the contract rather than restated beside it.
104+ const required = Array . isArray ( schema . required ) && schema . required . includes ( key ) ;
105+ if ( typeof prop . xExpression === 'string' ) out . push ( { path : here , marker : prop . xExpression , required } ) ;
100106 if ( isObjectArray ) out . push ( ...collectExpressionProps ( prop . items , here ) ) ;
101107 else out . push ( ...collectExpressionProps ( prop , here ) ) ;
102108 }
@@ -110,7 +116,8 @@ function collectExpressionProps(
110116 const values = schema . additionalProperties ;
111117 if ( values && typeof values === 'object' ) {
112118 const here = prefix ? `${ prefix } .*` : '*' ;
113- if ( typeof values . xExpression === 'string' ) out . push ( { path : here , marker : values . xExpression } ) ;
119+ // A map value is never "required": the map's keys are the author's own.
120+ if ( typeof values . xExpression === 'string' ) out . push ( { path : here , marker : values . xExpression , required : false } ) ;
114121 out . push ( ...collectExpressionProps ( values , here ) ) ;
115122 }
116123 return out ;
@@ -119,7 +126,7 @@ function collectExpressionProps(
119126const engine = new AutomationEngine ( silentLogger ( ) ) ;
120127installBuiltinNodes ( engine , ctx ( ) ) ;
121128
122- type DeclaredSlot = { nodeType : string ; path : string ; role : FlowNodeExpressionRole } ;
129+ type DeclaredSlot = { nodeType : string ; path : string ; role : FlowNodeExpressionRole ; required : boolean } ;
123130
124131/** Resolve an `xExpression` marker to its ledger role, failing loudly on an unknown one. */
125132function roleOf ( nodeType : string , path : string , marker : string ) : FlowNodeExpressionRole {
@@ -137,8 +144,8 @@ function declaredFromDescriptors(): DeclaredSlot[] {
137144 const found : DeclaredSlot [ ] = [ ] ;
138145 for ( const descriptor of engine . getActionDescriptors ( ) ) {
139146 const schema = descriptor . configSchema as SchemaNode | undefined ;
140- for ( const { path, marker } of collectExpressionProps ( schema ) ) {
141- found . push ( { nodeType : descriptor . type , path, role : roleOf ( descriptor . type , path , marker ) } ) ;
147+ for ( const { path, marker, required } of collectExpressionProps ( schema ) ) {
148+ found . push ( { nodeType : descriptor . type , path, role : roleOf ( descriptor . type , path , marker ) , required } ) ;
142149 }
143150 }
144151 return found ;
@@ -163,8 +170,8 @@ function declaredFromDescriptors(): DeclaredSlot[] {
163170function declaredFromSchemalessConfigs ( ) : DeclaredSlot [ ] {
164171 const found : DeclaredSlot [ ] = [ ] ;
165172 for ( const [ nodeType , json ] of Object . entries ( getSchemalessNodeConfigJsonSchemas ( ) ) ) {
166- for ( const { path, marker } of collectExpressionProps ( json as SchemaNode ) ) {
167- found . push ( { nodeType, path, role : roleOf ( nodeType , path , marker ) } ) ;
173+ for ( const { path, marker, required } of collectExpressionProps ( json as SchemaNode ) ) {
174+ found . push ( { nodeType, path, role : roleOf ( nodeType , path , marker ) , required } ) ;
168175 }
169176 }
170177 return found ;
@@ -213,6 +220,23 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
213220 expect ( stale , 'stale ledger entries — no descriptor or schemaless schema declares these' ) . toEqual ( [ ] ) ;
214221 } ) ;
215222
223+ /**
224+ * [#19961] The ledger's `required` flag is what makes the resolver emit an
225+ * ABSENT value for the doors to refuse — so it must say exactly what the
226+ * declaring channel's `required` list says, in both directions. A flag the
227+ * contract does not back would refuse a legal omission (an absent
228+ * `visibleWhen` shows the field); a requirement the ledger misses is the
229+ * #19961 shape again — declared required, admitted absent at every door.
230+ */
231+ it ( 'the ledger marks `required` exactly the slots the declaring channel requires (#19961)' , ( ) => {
232+ const declared = declaredEverywhere ( ) ;
233+ const requiredByChannel = declared . filter ( ( d ) => d . required ) . map ( key ) . sort ( ) ;
234+ const requiredByLedger = FLOW_NODE_EXPRESSION_PATHS . filter ( ( e ) => e . required ) . map ( key ) . sort ( ) ;
235+ expect ( requiredByLedger , 'ledger `required` flags disagree with the declaring channel' ) . toEqual ( requiredByChannel ) ;
236+ // Non-vacuous: the one required slot there is today is derived, not assumed.
237+ expect ( requiredByChannel ) . toEqual ( [ 'decision.conditions[].expression (predicate)' ] ) ;
238+ } ) ;
239+
216240 it ( 'decision.conditions[].expression is covered — the #4439 hole' , ( ) => {
217241 const decision = FLOW_NODE_EXPRESSION_PATHS . find (
218242 ( e ) => e . nodeType === 'decision' && e . path === 'conditions[].expression' ,
0 commit comments