Skip to content

Commit 0539b78

Browse files
committed
test(service-automation): re-judge the absent-branch registration pin; reconcile required over the predicate role (#19961)
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9cea1d3 commit 0539b78

3 files changed

Lines changed: 33 additions & 8 deletions

File tree

‎packages/services/service-automation/src/builtin/config-expression-ledger.test.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,14 +227,24 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
227227
* contract does not back would refuse a legal omission (an absent
228228
* `visibleWhen` shows the field); a requirement the ledger misses is the
229229
* #19961 shape again — declared required, admitted absent at every door.
230+
*
231+
* Reconciled over the `predicate` role, the one role the flag acts on: the
232+
* resolver emits an absent value only for a required PREDICATE slot. The
233+
* channels require two `flow-template` slots too (`loop.collection`,
234+
* `map.collection`), and no door refuses their absence — their executors
235+
* parse their own config — so the flag stays off there, and the second
236+
* assertion pins that it is never set on another role.
230237
*/
231-
it('the ledger marks `required` exactly the slots the declaring channel requires (#19961)', () => {
232-
const declared = declaredEverywhere();
238+
it('the ledger marks `required` exactly the predicate slots the declaring channel requires (#19961)', () => {
239+
const declared = declaredEverywhere().filter((d) => d.role === 'predicate');
233240
const requiredByChannel = declared.filter((d) => d.required).map(key).sort();
234-
const requiredByLedger = FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.required).map(key).sort();
241+
const requiredByLedger = FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'predicate' && e.required).map(key).sort();
235242
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.
243+
expect(FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role !== 'predicate' && e.required).map(key), '`required` acts on the predicate role only').toEqual([]);
244+
// Non-vacuous: the one required predicate slot there is today is derived,
245+
// not assumed — and the optional one (`visibleWhen`) is derived as optional.
237246
expect(requiredByChannel).toEqual(['decision.conditions[].expression (predicate)']);
247+
expect(declared.filter((d) => !d.required).map(key)).toEqual(['screen.fields[].visibleWhen (predicate)']);
238248
});
239249

240250
it('decision.conditions[].expression is covered — the #4439 hole', () => {

‎packages/services/service-automation/src/decision-predicate-envelope.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,23 @@ describe('decision branch predicate — envelope in a `z.string()` slot (#15572)
121121
* string is now refused here too — by `FlowSchema.parse` inside
122122
* `registerFlow`, under this slot's own sentence. A non-blank string and an
123123
* absent predicate still register, exactly as this test always said.
124+
*
125+
* RE-JUDGED IN PLACE AGAIN (#19961) — the absent half. It pinned
126+
* `decisionFlow('str_absent', undefined)` as registering: "an absent
127+
* predicate still registers". That was "not authored" on the resolver's
128+
* side only. `DecisionConditionSchema` declares `expression` a REQUIRED
129+
* `z.string()`, and the executor evaluates every branch it reaches, so a
130+
* branch with no `expression` failed the run at the branch — the build
131+
* accepted what the run refused. The ledger now marks the slot `required`
132+
* and the absent value is refused through the same `predicateSlotRefusal`
133+
* as the blank, under the same sentence, with its own detail.
124134
*/
125-
it('leaves non-blank string predicates alone — and refuses the whitespace-only one (#17493)', () => {
135+
it('leaves non-blank string predicates alone — and refuses the whitespace-only one (#17493) and the absent one (#19961)', () => {
126136
expect(() => engine.registerFlow('str_ok', decisionFlow('str_ok', 'record.rating >= 4'))).not.toThrow();
127137
expect(() => engine.registerFlow('str_ws', decisionFlow('str_ws', ' '))).toThrow(PREDICATE_SLOT_STRING_REFUSAL);
128-
expect(() => engine.registerFlow('str_absent', decisionFlow('str_absent', undefined))).not.toThrow();
138+
const absent = () => engine.registerFlow('str_absent', decisionFlow('str_absent', undefined));
139+
expect(absent).toThrow(PREDICATE_SLOT_STRING_REFUSAL);
140+
expect(absent).toThrow('Found nothing — the key is absent where the slot is required');
129141
});
130142

131143
/**

‎packages/spec/src/automation/flow-node-expression-paths.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,11 @@ export interface FlowNodeExpressionPath {
165165
*
166166
* Reconciled against the channel's own `required` list by the ratchet that
167167
* reconciles the markers (`config-expression-ledger.test.ts` in
168-
* `service-automation`), in both directions, so this flag cannot claim a
169-
* requirement the contract does not make, nor miss one it does.
168+
* `service-automation`), in both directions over the `predicate` role, so
169+
* this flag cannot claim a requirement the contract does not make, nor miss
170+
* one it does. Never set on another role: the channels require
171+
* `loop.collection` / `map.collection` too, but no door refuses their
172+
* absence — their executors parse their own config.
170173
*/
171174
readonly required?: true;
172175
}

0 commit comments

Comments
 (0)