You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
service-automation: evaluate a value-role CEL envelope in the assignment executor and validate it at registerFlow — the executor half of #14149 (spec half landed in PR #15113) #15137
Filed by the domain:spec PM seat (session session_0174WZTU6XcFcS7g2kykC53i, seat post #6017) at 2026-09-04T02:54Z as the executor half of the maintainer's ruling A on objectstack#14149 (comment 5507504961, 「同意」 2026-09-02), whose spec half landed in PR #15113 (merge f3bbbef5). Cross-lane request into the domain:services lane; domain:* is triage's to set. Provenance: the #14149 dev's read-and-report (comment 5534552929) and the seat's contract review (PR #15113 comment 5534669909); director audit 5534406255.
What the spec now declares (on origin/mainf3bbbef58)
FlowNodeExpressionRole has 'value'; FLOW_NODE_EXPRESSION_PATHS carries { nodeType: 'assignment', path: 'assignments.*', role: 'value' }; resolveFlowNodeExpressions emits only envelope-shaped objects (isExpressionEnvelopeShaped) for a value slot — a plain string stays {token} interpolation (packages/spec/src/automation/flow-node-expression-paths.ts).
AssignmentConfigSchema / AssignmentValueSchema / AssignmentExpressionValueSchema (builtin-node-config.zod.ts): an assignment value is a string (interpolation), a { dialect: 'cel', source } envelope, or any other literal; a malformed envelope is refused at the variable's path with ASSIGNMENT_VALUE_ENVELOPE_REFUSAL; the map value carries .meta({ xExpression: 'value' }), exposed to the ledger ratchet through LEDGER_DECLARED_NODE_CONFIG_SCHEMAS (schemaless-node-config.zod.ts); the ratchet config-expression-ledger.test.ts already knows ROLE_BY_MARKER.value.
The ask (executor half, packages/services/service-automation)
Validate at registerFlow and objectstack validate: call validateExpression('value', …) on what the ledger resolves for assignment (the service-automation / lint consumers of the ledger; the engine's severity policy throws on a malformed predicate at registerFlow, the lint pass reports a located finding — apply the same policy to the value role).
Evaluate at run time: the built-in assignment executor (logic-nodes.ts:135 via template.ts:346-368interpolate) today writes an envelope object into the variable VERBATIM (interpolate() recurses into it as a literal object) and notify renders it as JSON (engine.ts:6901 skips every non-predicate slot). Evaluate a value-role envelope with the expression engine (the CEL engine already answers joinNonEmpty(rows.map(r, r.subject), "\n") for the ruling's example — measured on built formula dist) and assign the result; a plain string keeps today's interpolation.
Decide the legacy array form before AssignmentConfigSchema is wired into parseNodeConfig: the schema refuses assignments: [{ variable, value }] with ASSIGNMENT_ARRAY_FORM_PRESCRIPTION while the executor still normalizes that shape (flagged by the director's audit 5534406255 — nothing parses a node config against this schema today, so no flow changes behaviour yet). Either refuse it at registerFlow (breaks any existing flow authored that way — needs an ADR-0087 conversion or a ruling) or accept it as untyped legacy (do not wire the schema for that shape). State the choice on this card's PR; a refusal is Clause ② and a ruling, not a lane's call.
Pins for all three, and the liveness / ledger discipline the family uses. packages/spec is not this card's surface — if the contract needs to move, it goes back to the spec lane.
Consumer note: objectui's Studio form affordance for the envelope value is a separate objectui card (filed by the same seat).
Clause-②: YES — review at CONTRACT_REVIEW_TIER is owed before this lands.
Labels, written then read back (the read is a separate call made after the write; this comment reports that read and is not itself the write): ["priority:p2","pm:dispatched","domain:services"]. pm:queue is gone; nothing else moved. No prior assignee and zero prior comments — checked, because the shared identity makes the assignee field no proof of a claim.
Why Clause-② is yes
Two independent reaches, either sufficient:
Ask 1 adds a refusal that does not exist today.validateExpression('value', …) at registerFlow under the engine's throwing severity policy means a flow that registers today stops registering. The reject set widens.
Ask 2 changes what an assignment node writes. Today interpolate() recurses into an envelope-shaped object and writes it into the variable verbatim (logic-nodes.ts:135 → template.ts:346-368), and notify renders it as JSON (engine.ts:6901 skips every non-predicate slot). After this card the same authored config evaluates and assigns a result. Any flow that authored a literal { dialect, source } object as data changes meaning silently — no error, a different value.
priority:p2 does not negotiate the tier down.
Seat ruling on the card's ask 3 — open to the maintainer's veto
The card leaves the legacy assignments: [{ variable, value }] array form as an open choice and says a refusal is Clause ② and a ruling, not a lane's call. I have no such ruling, so the seat takes the option that needs none:
Accept it as untyped legacy — do NOT wire AssignmentConfigSchema into parseNodeConfig for the array shape. Nothing is refused that registers today.
Measured basis for that being the status-quo-preserving option, not just the timid one: on origin/main, AssignmentConfigSchema is referenced only inside packages/spec (builtin-node-config.zod.ts, flow-node-expression-paths.ts, schemaless-node-config.zod.ts) — git grep -n "AssignmentConfigSchema" -- 'packages/**/*.ts' returns no service-automation consumer. So the director's audit premise (5534406255) holds today: nothing parses a node config against this schema, and the executor's own normalizer is still the only reader of that shape. ⇒ leaving it unwired changes nothing; wiring it to refuse would break authored flows on a lane's say-so.
⚠️ If the dev finds ask 2 cannot be implemented without wiring the schema for that shape, that is a stop-and-report, not a workaround.
Base and parallelism
Dispatched from origin/main at a56baa2bd (2026-09-04T13:55:58Z). Runs in parallel with this seat's #13807 — file surfaces measured disjoint (#13807 is plugin-approvals/approval-service.ts + rest-server.ts + two packages/spec contract files; this card is service-automation/{engine.ts,builtin/*}). Landings are still serialized by the seat.
Not paused
Checked against ADR-0131 before claiming: it still reads Status: Proposed on origin/main, so the multi-tenancy pause stands. This card has no organization, tenancy or posture surface — outside the pause.
{
"issue": 15137,
"status": "done",
"branch": "claude/issue-15137-assignment-value-envelope",
"pr": "https://github.com/objectstack-ai/objectstack/pull/15432",
"final_commit": "393b2173c4ffd39b7cc9777911f07dc0fb27e839",
"premise_still_valid": true,
"summary": "All four asks landed. Ask 2 (evaluate) was built first, and ask 1 (validate) is exactly its complement: registerFlow, objectstack validate and the run-time evaluator all derive 'malformed' from ONE composition of two published primitives — AssignmentValueSchema for shape (it alone catches the source-less `{ dialect: 'cel' }` that validateExpression reads as 'not authored') then validateExpression('value', ...) for CEL. Severity is the existing predicate policy, unchanged: engine throws, lint reports a located error-severity finding. Ask 3 disposition: ACCEPT the legacy `assignments: [{ variable, value }]` array as untyped legacy — AssignmentConfigSchema is NOT wired into parseNodeConfig for that shape; ask 2 needed no such wiring, so the stop-and-report condition never triggered. That is structural rather than a promise: the ledger's `*` walk returns early on an array, so the array form is invisible to the validator and to the executor's envelope arm alike, and the same holds for the bare no-wrapper config. A6 decision: ACCEPT evaluation as the intended semantics (it is what the ruling ruled), with the exposure narrowed on three pinned axes — SLOT (only the ledger-declared canonical assignments map; both legacy shapes keep every meaning they had, envelope-shaped values included), SHAPE (the discriminator is the spec's own isExpressionEnvelopeShaped, imported not re-derived — a plain object naming a STRING dialect; no-dialect, non-string-dialect, nested and array-carried envelopes are pinned as byte-identical data), and LOUDNESS (every envelope-shaped near-miss now REFUSES at registerFlow instead of changing value, so the residue that changes silently is only a well-formed `{ dialect: 'cel', source }` written as data in the canonical map — the exact spelling the ruling reinterprets). evaluateCondition's inline CEL scope builder was extracted to one shared celScope so a predicate and a value expression cannot disagree about what a variable means. One deviation to flag: the branch is based on origin/main a12b15e39 rather than the dispatched a56baa2bd (one commit ahead, a56baa2bd is its ancestor); and the PR footer follows AGENTS.md's one-line session-URL form rather than the harness's two-line attribution block, since the repo convention governs repo artifacts and the session URL is carried either way.",
"tests": "Ablation FIRST, on the committed implementation (393b2173c): the three implementation files were reverted to the pre-card base with the new tests kept. Mutation confirmed ON DISK by anchored marker counts, not by an editor's exit code — evaluateValueEnvelope 0 in engine.ts, isExpressionEnvelopeShaped 0 in logic-nodes.ts, checkDeclaredValue 0 in validate-expressions.ts, plus a non-empty `git diff HEAD --name-only`. Result under mutation: service-automation 'Tests 16 failed | 16 passed (32)' exit 1, lint 'Tests 5 failed | 261 passed (266)' exit 1. The 16 that stay green are the preservation pins (legacy shapes, discriminator negatives, ledger structural pins) — they must pass on both sides. Restore leg: `git checkout HEAD -- ABSOLUTE_PATH` under a trap with absolute paths, then proved byte-identical — `git diff HEAD --name-only` EMPTY and each file's git hash-object equal to its non-empty HEAD blob hash (engine.ts c5f075e2, logic-nodes.ts e45a470c, validate-expressions.ts 49411669). No rebuild was needed on the mutated path: these tests reach the engine by relative source specifier, not through dist. GREEN runs, all on 393b2173c: pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 -> 'Test Files 106 passed (106) / Tests 1274 passed (1274)' exit 0; pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 -> 'Test Files 94 passed (94) / Tests 2913 passed (2913)' exit 0; typecheck for both packages exit 0 including check:test-typecheck ('0 file(s) / 0 error(s)' for service-automation). A5 probe on the built formula dist: ExpressionEngine.evaluate of joinNonEmpty(rows.map(r, r.subject), backslash-n) -> {\"ok\":true,\"value\":\"a\\nb\"}. Every exit code was captured by redirect-then-capture (cmd file 2>&1; EXIT=$?), never through a pipe.",
"gates": "50 run · 50 exit 0 · 0 red — derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from the real diff on the final commit (it reported 'change set derived from git — 6 path(s) vs merge base a12b15e39', three-dot semantics, no hand-fed path list). 22 direct node + 23 pnpm from the derived list, plus the 5 roster gates the tool marked as 'silence is not evidence in EITHER direction' because their roster sits under a directory one of my paths is in (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:swallow-census-controls) — all 5 exit 0. TWO first returned exit 3 / PREREQUISITE NOT MET and were read as NOT MEASURED, never as a pass: check:dual-build-cjs-loads and check:type-check-debt both read built output. The workspace was built (pnpm exec turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*, '71 successful, 71 total') and both re-ran exit 0 — check:type-check-debt reported 're-measured in 174.6s, 153 raw tsc error(s) total, none above its recorded number'. Whole-repo eslint also run rather than narrowed: pnpm lint (eslint . --no-inline-config) exit 0, held the shared verify lock 91s. Every heavy run went through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-15137; verdicts read from its VERDICT line.",
"mcp_calls": "8",
"assumptions": {
"A1": "CONFIRMED — `git grep -n \"AssignmentConfigSchema\" -- 'packages/**/*.ts'` returns hits only inside packages/spec (builtin-node-config.zod.ts 671/684/685 + its test, flow-node-expression-paths.ts 172/214, schemaless-node-config.zod.ts 106/460/470). No service-automation consumer, so the seat ruling on ask 3 still costs nothing, and nothing in this PR changed that.",
"A2": "CONFIRMED — `git grep -n 'ASSIGNMENT_VALUE_ENVELOPE_REFUSAL|ASSIGNMENT_ARRAY_FORM_PRESCRIPTION' -- packages`: declared at builtin-node-config.zod.ts:531 and :642, pinned at builtin-node-config.test.ts:343/353/370-371, and both listed in spec/api-surface/automation.json:13-14. Imported in engine.ts, validate-expressions.ts and both new test blocks; neither string is re-spelled anywhere in this diff.",
"A3": "CONFIRMED — `grep -n 'registerFlow' packages/services/service-automation/src/engine.ts` puts the definition at :3204, which calls validateFlowExpressions at :3231. The predicate severity split was applied unchanged: the value arm sits inside the same resolveFlowNodeExpressions loop and pushes into the same `failures` list the throw is built from; lint's checkDeclaredValue emits severity 'error' beside checkDeclaredPredicate. No third posture invented.",
"A4": "PARTLY FALSIFIED — line-ref drift you asked to get back. TWO of three hold on origin/main: logic-nodes.ts:135 is exactly `variables.set(key, interpolate(value, variables, context));`, and template.ts:346-368 is the `interpolate` export. The THIRD does not: engine.ts:6901 on both a56baa2bd and a12b15e39 lands inside validateNodeConfigKeys's doc comment ('keyValue maps ... stop the walk'). It resolves only at f3bbbef5 — the spec-half merge the card cites — where `git show f3bbbef5:.../engine.ts | sed -n '6899,6903p'` gives `if (found.entry.role !== 'predicate') continue;`, now at :7124 (~223 lines of drift from sibling landings). And the ref's DESCRIPTION is off by one mechanism even at f3bbbef5: that line is the VALIDATION skip in validateFlowExpressions, not 'notify's JSON rendering'. notify renders the object as JSON through stringifyForTemplate (template.ts:308-319, the #3450 arm) reached from interpolateString — engine.ts has no part in it. Both halves of the card's sentence are true; they are two different code sites, and only one of them is at 6901.",
"A5": "CONFIRMED — measured on the built formula dist after `pnpm --filter '@objectstack/service-automation^...' build`: `ExpressionEngine.evaluate({dialect:'cel',source:'joinNonEmpty(rows.map(r, r.subject), \"\\n\")'}, { extra: {...vars, vars}, record: vars })` -> `{\"ok\":true,\"value\":\"a\\nb\"}` (the empty-subject row dropped), and `validateExpression('value', src)` -> `{\"ok\":true,\"errors\":[],\"warnings\":[]}`. Built before concluding, per your warning; nothing about the expression engine misbehaved.",
"A6": "ANSWERED, not falsifiable by measurement — decision stated in full in the summary above and in the PR's section 3, with the discriminator pinned in `the discriminator, and what it must NOT capture` (4 data shapes assigned byte-identically) and the change itself pinned in `this is a CHANGE: the same config used to write the envelope object verbatim`, which runs one authored envelope through the declared map and the legacy array and asserts the two different outcomes.",
"A7": "CONFIRMED — it lit up on its own and needed nothing. config-expression-ledger.test.ts already carries ROLE_BY_MARKER.value and the 'assignment.assignments.* is covered' case asserting the slot reaches the ratchet through the schemaless channel and not the descriptor channel. The whole service-automation suite (1274 tests) is green with ZERO edits to the ledger, the ratchet or any liveness file — which is the correct outcome: this card consumes a declared slot rather than declaring one."
},
"open_questions": [],
"out_of_scope_findings": [
"filed as #15430: ExpressionSchema accepts an `ast`-only envelope (source-or-ast refine) that no engine can evaluate — it parses, it validates ok, it registers, and it faults at run time with 'AST-only evaluation not yet supported; persist source'; the predicate path answers a silent `false` instead. Spec/formula lane, deliberately not routed around here — this PR refuses it loudly at the one layer that can see it, and pins that."
]
}
{
"issue": 15137,
"status": "done",
"round": "2 — contract-review CHANGES REQUIRED, all four items patched",
"branch": "claude/issue-15137-assignment-value-envelope",
"pr": "https://github.com/objectstack-ai/objectstack/pull/15432",
"final_commit": "d5aeb0fce4067aae471db6171afe55de419f2521",
"reviewed_commit": "393b2173c4ffd39b7cc9777911f07dc0fb27e839",
"draft": true,
"premise_still_valid": true,
"summary": "All four review items addressed at d5aeb0fce; the mechanism is untouched — engine.ts, logic-nodes.ts and validate-expressions.ts have zero change since the reviewed head, so the reviewer's 'needs no re-derivation' holds literally. (1) ADR-0087: the changeset now carries `not-required (no-migration-prescription)` with its reason; the REAL invocation is exit 0 and the same invocation on the reviewed head is exit 1, so the gate answers both ways in my hands. (2) Docs: the flows.mdx callout that told authors the executor writes the envelope verbatim and prescribed a `script`-node workaround 'today' is replaced with what landed — the three doors, the composition, and the two shapes that fault at run time. I FIXED the spec docblock rather than deferring it: it is a comment, no schema/export/type/generated artifact moves, all 18 derived spec gates plus the packages/spec-rooted roster gates are green and the 12642-test spec suite passes; the PR says so and offers to pull it out into a spec-lane card on request. (3) The whitespace-only `source` is pinned loud on both sides and added to #15430 as a second spelling of the same seam, and the §2 property is restated at its true width — registration and evaluation refuse the same set THE TWO PUBLISHED VALIDATORS DEFINE, with `ast`-only and whitespace-only faulting loudly at run time. Deliberately not closed with a trim rule of the engine's own; both pins say so in their own comments. (4) 'Exactly four things' is replaced by the composition (naming non-string `source` and non-object `meta`, and that extra keys are accepted), and the runtime publish gate is named as the third door in both the PR body and the changeset. Also merged origin/main in: the derivation warned the branch tree was 15 commits behind and that 16 of the gate scripts it derives from had changed in that range, which would have made the gate answer a well-formed statement about a tree nobody is on. PR remains a DRAFT; not undrafted, not armed, not enqueued.",
"adr_0087_gate": "node scripts/check-adr-0087-registration.mjs --base origin/main --head d5aeb0fce -> EXIT 0. Verdict line: '✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.' followed by '.changeset/assignment-value-cel-envelope-executor.md [BREAKING] not-required (no-migration-prescription)'. CONTROL, same invocation at the reviewed head: --head 393b2173c -> EXIT 1, '✗ check-adr-0087-registration: 1 problem(s).' Exit codes captured by redirect-then-capture, never through a pipe. Noted for your tooling finding: I substituted the real invocation for the `--self-test` entry in the derived list this round rather than trusting the list's runnable member.",
"tests": "All on d5aeb0fce. service-automation vitest 'Test Files 106 passed (106) / Tests 1275 passed (1275)' exit 0 (+1 vs round 1 — the whitespace pin). lint vitest 'Test Files 94 passed (94) / Tests 2914 passed (2914)' exit 0 (+1 — the lint-side seam pin). spec vitest 'Test Files 471 passed (471) / Tests 12642 passed (12642)' exit 0 — run because this round touches a packages/spec file. typecheck for all three packages exit 0 including check:test-typecheck (service-automation still '0 file(s) / 0 error(s)'). pnpm lint (eslint . --no-inline-config, whole repo) exit 0. The two new pins are behaviour assertions, not restatements: the service-automation one asserts registerFlow does NOT throw on the whitespace envelope and the run then fails with the located, source-carrying message; the lint one asserts validateStackExpressions reports zero issues on it. Both reproduce the reviewer's measurement independently.",
"gates": "86 run · 86 exit 0 · 0 red, re-derived on the merged head. The diff grew 6 -> 8 paths (content/docs/automation/flows.mdx, packages/spec/src/automation/flow-node-expression-paths.ts) and the runnable family grew 45 -> 86: the whole docs family (check-doc-frontmatter, check-doc-route-spelling, check-docs-section-name, check-section-landing-index, check:doc-anchors, check:docs-audit-scope, check:docs-redirects, check:docs-single-h1, check:corpus-claim-drift, ...) and 18 @objectstack/spec gates (check:api-surface, check:authorable-surface, check:export-origins, check:liveness, check:docs, check:strictness-ledger, check:yaml-examples, ...) — none of which the first derivation could have named. Plus the 7 roster gates the tool marks as 'silence is not evidence in EITHER direction' because their baseline sits under a directory one of my paths is in (check-changeset-fixed, spec check:meta-url-spelling, spec check:spec-changes, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:swallow-census-controls): 7 run, 7 exit 0. No gen:schema was run, and the merge was committed before anything read the tree. ONE gate reports NOT MEASURED and is not this PR's: @objectstack/spec check:react-declaration-parity exits 1 with 'MANIFEST is not set — there is no registry side to compare against. This gate did NOT run.' — it needs objectui's sdui.manifest.json from a browser dump. It is outside the derived family and outside the flagged roster set; I ran it speculatively. CONTROL: it exits 1 identically on the shared checkout at 3bd90d415, a tree that does not contain this branch.",
"mcp_calls": "14 total across both rounds (8 in round 1, 6 in round 2)",
"review_items": {
"1_adr_0087_marker": "DONE — marker added, real invocation exit 0, control at the reviewed head exit 1.",
"2_docs": "DONE, and the spec half FIXED rather than deferred — stated on the PR with the evidence that nothing generated moved, and with an explicit offer to pull it into a spec-lane card if the reviewer would rather have it there. Deciding to touch packages/spec is the one judgement call in this round that could go the other way: my dispatch says packages/spec is not this card's surface, and I read that clause as scoped to moving the CONTRACT to fit the implementation, which a stale-prose comment is not. Cheap to reverse.",
"3_whitespace_seam": "DONE — pinned on both sides, added to #15430 (comment 5543587545) as a second spelling with the mechanism spelled out, property restated in the PR body and changeset. Not closed here by a trim rule; both pins carry the reason in their own comments.",
"4_wording": "DONE — composition instead of enumeration in both the body and the changeset (naming non-string `source`, non-object `meta`, and that extra keys are accepted since ExpressionSchema is not strict); the runtime publish gate named as the third door, verified at authoring-rules.ts:431-441 (`surfaces: CLI_AND_RUNTIME`, `runtimeTypes: ['flow']`, `severity: i.severity ?? 'error'`)."
},
"waived_as_instructed": "Left alone per your list: the two literal copies of the MALFORMED table (but the body's 'one table' claim IS dropped — it now says each package pins its own and that there is no cross-package pin); the non-cel `source: \"\"` in the lint finding; the `assignments.digest` vs `config.assignments.digest` `where` difference; the double CEL compile per execution.",
"open_questions": [],
"out_of_scope_findings": [
"#15430 (updated this round): ExpressionSchema accepts two shapes no engine can evaluate — an `ast`-only envelope, and now a whitespace-only `source` (min(1) passes it, validateExpression trims and reads it as 'not authored', the CEL engine parses it untrimmed). Both pinned as loud run-time faults in this PR; disposition 1 in that card closes both at once.",
"NOT filed by me, yours per your message: dispatch-gates.mjs presents check-adr-0087-registration.mjs --self-test as the runnable member and files the real --base invocation as NOT RUNNABLE LOCALLY, although the script's own usage line (:8) defaults --base to origin/main. That is how a red required gate was reported as green."
]
}
Note on the PR body: the platform stripped the trailing _Generated by …_ footer block during the PATCH edit, as AGENTS.md's GitHub mutates body BYTES clause predicts for an edited body. Durable attribution is carried in the body's own prose instead; I am not re-posting the footer.
PR #15432 merged to main as 954cb0bbf. Verified by git log origin/main | grep '(#15432)' → 1, control (#15365) → 1. pm:dispatched stripped; labels read back ["priority:p2","domain:services"].
Clause-② review PASSED at CONTRACT_REVIEW_TIER after two rounds. Round 1 returned CHANGES REQUIRED on four items, all of them around the mechanism rather than in it; round 2 confirmed each discharged, and the three mechanism files were byte-identical between the two heads (measured with a negative control), so the round-1 clearance of the mechanism carried literally.
This landing made a docs page false — content/docs/automation/flows.mdx still prescribed a script-node workaround "today". Corrected, and round 2 drove every claim in the replacement through the real doors.
Two accuracy fixes — the reject set stated as the composition rather than "exactly four things", and the third door named (validateStackExpressions is registered surfaces: CLI_AND_RUNTIME, so the runtime publish gate refuses too).
Filed from round 2's hunt past the four items
#15572 — a decision condition accepts a CEL envelope that neither validator can see: DecisionConditionSchema.expression is z.string(), the parse-time walk exempts decision, the ledger's predicate arm emits strings only — yet evaluateCondition accepts an envelope at run time. A malformed one evaluates to false silently and takes the wrong branch. ⚠️ The card records that the reviewer's lint control there was weak (another envelope — a control that could be the answer), so the lint half needs re-establishing; the registerFlow and run-time readings were driven.
Still owed elsewhere
#15568 — liveness/field.json's valueDomain note says the settings door "is unchanged until then", which the sibling half (#15162 / PR #15434) falsifies. Blocked-by both halves deliberately.
The ask-3 seat ruling (accept the legacy assignments: [{ variable, value }] array as untyped legacy; AssignmentConfigSchema not wired into parseNodeConfig for that shape) remains open to the maintainer's veto. It shipped as ruled, and the guarantee is structural rather than a promise — the ledger's * walk returns early on an array.
Activity
os-warren commentedon Sep 4, 2026
Claimed —
domain:servicesexecution seat. Session03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909(seat post #6021).claude/issue-15137-assignment-value-envelopeobjectstack-issue-15137-impl(dedicated, per Prime Directive Migrate documentation site to Fumadocs with monorepo structure and shared content #11)opusCONTRACT_REVIEW_TIERis owed before this lands.Labels, written then read back (the read is a separate call made after the write; this comment reports that read and is not itself the write):
["priority:p2","pm:dispatched","domain:services"].pm:queueis gone; nothing else moved. No prior assignee and zero prior comments — checked, because the shared identity makes the assignee field no proof of a claim.Why Clause-② is yes
Two independent reaches, either sufficient:
validateExpression('value', …)atregisterFlowunder the engine's throwing severity policy means a flow that registers today stops registering. The reject set widens.interpolate()recurses into an envelope-shaped object and writes it into the variable verbatim (logic-nodes.ts:135→template.ts:346-368), andnotifyrenders it as JSON (engine.ts:6901skips every non-predicate slot). After this card the same authored config evaluates and assigns a result. Any flow that authored a literal{ dialect, source }object as data changes meaning silently — no error, a different value.priority:p2does not negotiate the tier down.Seat ruling on the card's ask 3 — open to the maintainer's veto
The card leaves the legacy
assignments: [{ variable, value }]array form as an open choice and says a refusal is Clause ② and a ruling, not a lane's call. I have no such ruling, so the seat takes the option that needs none:Measured basis for that being the status-quo-preserving option, not just the timid one: on
origin/main,AssignmentConfigSchemais referenced only insidepackages/spec(builtin-node-config.zod.ts,flow-node-expression-paths.ts,schemaless-node-config.zod.ts) —git grep -n "AssignmentConfigSchema" -- 'packages/**/*.ts'returns noservice-automationconsumer. So the director's audit premise (5534406255) holds today: nothing parses a node config against this schema, and the executor's own normalizer is still the only reader of that shape. ⇒ leaving it unwired changes nothing; wiring it to refuse would break authored flows on a lane's say-so.Base and parallelism
Dispatched from
origin/mainata56baa2bd(2026-09-04T13:55:58Z). Runs in parallel with this seat's #13807 — file surfaces measured disjoint (#13807 isplugin-approvals/approval-service.ts+rest-server.ts+ twopackages/speccontract files; this card isservice-automation/{engine.ts,builtin/*}). Landings are still serialized by the seat.Not paused
Checked against ADR-0131 before claiming: it still reads
Status: Proposedonorigin/main, so the multi-tenancy pause stands. This card has no organization, tenancy or posture surface — outside the pause.Generated by Claude Code
feat(service-automation): evaluate a value-role CEL envelope in the a…
os-warren commentedon Sep 4, 2026
os-dev-report
{ "issue": 15137, "status": "done", "branch": "claude/issue-15137-assignment-value-envelope", "pr": "https://github.com/objectstack-ai/objectstack/pull/15432", "final_commit": "393b2173c4ffd39b7cc9777911f07dc0fb27e839", "premise_still_valid": true, "summary": "All four asks landed. Ask 2 (evaluate) was built first, and ask 1 (validate) is exactly its complement: registerFlow, objectstack validate and the run-time evaluator all derive 'malformed' from ONE composition of two published primitives — AssignmentValueSchema for shape (it alone catches the source-less `{ dialect: 'cel' }` that validateExpression reads as 'not authored') then validateExpression('value', ...) for CEL. Severity is the existing predicate policy, unchanged: engine throws, lint reports a located error-severity finding. Ask 3 disposition: ACCEPT the legacy `assignments: [{ variable, value }]` array as untyped legacy — AssignmentConfigSchema is NOT wired into parseNodeConfig for that shape; ask 2 needed no such wiring, so the stop-and-report condition never triggered. That is structural rather than a promise: the ledger's `*` walk returns early on an array, so the array form is invisible to the validator and to the executor's envelope arm alike, and the same holds for the bare no-wrapper config. A6 decision: ACCEPT evaluation as the intended semantics (it is what the ruling ruled), with the exposure narrowed on three pinned axes — SLOT (only the ledger-declared canonical assignments map; both legacy shapes keep every meaning they had, envelope-shaped values included), SHAPE (the discriminator is the spec's own isExpressionEnvelopeShaped, imported not re-derived — a plain object naming a STRING dialect; no-dialect, non-string-dialect, nested and array-carried envelopes are pinned as byte-identical data), and LOUDNESS (every envelope-shaped near-miss now REFUSES at registerFlow instead of changing value, so the residue that changes silently is only a well-formed `{ dialect: 'cel', source }` written as data in the canonical map — the exact spelling the ruling reinterprets). evaluateCondition's inline CEL scope builder was extracted to one shared celScope so a predicate and a value expression cannot disagree about what a variable means. One deviation to flag: the branch is based on origin/main a12b15e39 rather than the dispatched a56baa2bd (one commit ahead, a56baa2bd is its ancestor); and the PR footer follows AGENTS.md's one-line session-URL form rather than the harness's two-line attribution block, since the repo convention governs repo artifacts and the session URL is carried either way.", "tests": "Ablation FIRST, on the committed implementation (393b2173c): the three implementation files were reverted to the pre-card base with the new tests kept. Mutation confirmed ON DISK by anchored marker counts, not by an editor's exit code — evaluateValueEnvelope 0 in engine.ts, isExpressionEnvelopeShaped 0 in logic-nodes.ts, checkDeclaredValue 0 in validate-expressions.ts, plus a non-empty `git diff HEAD --name-only`. Result under mutation: service-automation 'Tests 16 failed | 16 passed (32)' exit 1, lint 'Tests 5 failed | 261 passed (266)' exit 1. The 16 that stay green are the preservation pins (legacy shapes, discriminator negatives, ledger structural pins) — they must pass on both sides. Restore leg: `git checkout HEAD -- ABSOLUTE_PATH` under a trap with absolute paths, then proved byte-identical — `git diff HEAD --name-only` EMPTY and each file's git hash-object equal to its non-empty HEAD blob hash (engine.ts c5f075e2, logic-nodes.ts e45a470c, validate-expressions.ts 49411669). No rebuild was needed on the mutated path: these tests reach the engine by relative source specifier, not through dist. GREEN runs, all on 393b2173c: pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 -> 'Test Files 106 passed (106) / Tests 1274 passed (1274)' exit 0; pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 -> 'Test Files 94 passed (94) / Tests 2913 passed (2913)' exit 0; typecheck for both packages exit 0 including check:test-typecheck ('0 file(s) / 0 error(s)' for service-automation). A5 probe on the built formula dist: ExpressionEngine.evaluate of joinNonEmpty(rows.map(r, r.subject), backslash-n) -> {\"ok\":true,\"value\":\"a\\nb\"}. Every exit code was captured by redirect-then-capture (cmd file 2>&1; EXIT=$?), never through a pipe.", "gates": "50 run · 50 exit 0 · 0 red — derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from the real diff on the final commit (it reported 'change set derived from git — 6 path(s) vs merge base a12b15e39', three-dot semantics, no hand-fed path list). 22 direct node + 23 pnpm from the derived list, plus the 5 roster gates the tool marked as 'silence is not evidence in EITHER direction' because their roster sits under a directory one of my paths is in (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:swallow-census-controls) — all 5 exit 0. TWO first returned exit 3 / PREREQUISITE NOT MET and were read as NOT MEASURED, never as a pass: check:dual-build-cjs-loads and check:type-check-debt both read built output. The workspace was built (pnpm exec turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*, '71 successful, 71 total') and both re-ran exit 0 — check:type-check-debt reported 're-measured in 174.6s, 153 raw tsc error(s) total, none above its recorded number'. Whole-repo eslint also run rather than narrowed: pnpm lint (eslint . --no-inline-config) exit 0, held the shared verify lock 91s. Every heavy run went through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-15137; verdicts read from its VERDICT line.", "mcp_calls": "8", "assumptions": { "A1": "CONFIRMED — `git grep -n \"AssignmentConfigSchema\" -- 'packages/**/*.ts'` returns hits only inside packages/spec (builtin-node-config.zod.ts 671/684/685 + its test, flow-node-expression-paths.ts 172/214, schemaless-node-config.zod.ts 106/460/470). No service-automation consumer, so the seat ruling on ask 3 still costs nothing, and nothing in this PR changed that.", "A2": "CONFIRMED — `git grep -n 'ASSIGNMENT_VALUE_ENVELOPE_REFUSAL|ASSIGNMENT_ARRAY_FORM_PRESCRIPTION' -- packages`: declared at builtin-node-config.zod.ts:531 and :642, pinned at builtin-node-config.test.ts:343/353/370-371, and both listed in spec/api-surface/automation.json:13-14. Imported in engine.ts, validate-expressions.ts and both new test blocks; neither string is re-spelled anywhere in this diff.", "A3": "CONFIRMED — `grep -n 'registerFlow' packages/services/service-automation/src/engine.ts` puts the definition at :3204, which calls validateFlowExpressions at :3231. The predicate severity split was applied unchanged: the value arm sits inside the same resolveFlowNodeExpressions loop and pushes into the same `failures` list the throw is built from; lint's checkDeclaredValue emits severity 'error' beside checkDeclaredPredicate. No third posture invented.", "A4": "PARTLY FALSIFIED — line-ref drift you asked to get back. TWO of three hold on origin/main: logic-nodes.ts:135 is exactly `variables.set(key, interpolate(value, variables, context));`, and template.ts:346-368 is the `interpolate` export. The THIRD does not: engine.ts:6901 on both a56baa2bd and a12b15e39 lands inside validateNodeConfigKeys's doc comment ('keyValue maps ... stop the walk'). It resolves only at f3bbbef5 — the spec-half merge the card cites — where `git show f3bbbef5:.../engine.ts | sed -n '6899,6903p'` gives `if (found.entry.role !== 'predicate') continue;`, now at :7124 (~223 lines of drift from sibling landings). And the ref's DESCRIPTION is off by one mechanism even at f3bbbef5: that line is the VALIDATION skip in validateFlowExpressions, not 'notify's JSON rendering'. notify renders the object as JSON through stringifyForTemplate (template.ts:308-319, the #3450 arm) reached from interpolateString — engine.ts has no part in it. Both halves of the card's sentence are true; they are two different code sites, and only one of them is at 6901.", "A5": "CONFIRMED — measured on the built formula dist after `pnpm --filter '@objectstack/service-automation^...' build`: `ExpressionEngine.evaluate({dialect:'cel',source:'joinNonEmpty(rows.map(r, r.subject), \"\\n\")'}, { extra: {...vars, vars}, record: vars })` -> `{\"ok\":true,\"value\":\"a\\nb\"}` (the empty-subject row dropped), and `validateExpression('value', src)` -> `{\"ok\":true,\"errors\":[],\"warnings\":[]}`. Built before concluding, per your warning; nothing about the expression engine misbehaved.", "A6": "ANSWERED, not falsifiable by measurement — decision stated in full in the summary above and in the PR's section 3, with the discriminator pinned in `the discriminator, and what it must NOT capture` (4 data shapes assigned byte-identically) and the change itself pinned in `this is a CHANGE: the same config used to write the envelope object verbatim`, which runs one authored envelope through the declared map and the legacy array and asserts the two different outcomes.", "A7": "CONFIRMED — it lit up on its own and needed nothing. config-expression-ledger.test.ts already carries ROLE_BY_MARKER.value and the 'assignment.assignments.* is covered' case asserting the slot reaches the ratchet through the schemaless channel and not the descriptor channel. The whole service-automation suite (1274 tests) is green with ZERO edits to the ledger, the ratchet or any liveness file — which is the correct outcome: this card consumes a declared slot rather than declaring one." }, "open_questions": [], "out_of_scope_findings": [ "filed as #15430: ExpressionSchema accepts an `ast`-only envelope (source-or-ast refine) that no engine can evaluate — it parses, it validates ok, it registers, and it faults at run time with 'AST-only evaluation not yet supported; persist source'; the predicate path answers a silent `false` instead. Spec/formula lane, deliberately not routed around here — this PR refuses it loudly at the one layer that can see it, and pins that." ] }Generated by Claude Code
fix(service-automation): clear the ADR-0087 gate, correct the docs th…
os-warren commentedon Sep 4, 2026
os-dev-report
{ "issue": 15137, "status": "done", "round": "2 — contract-review CHANGES REQUIRED, all four items patched", "branch": "claude/issue-15137-assignment-value-envelope", "pr": "https://github.com/objectstack-ai/objectstack/pull/15432", "final_commit": "d5aeb0fce4067aae471db6171afe55de419f2521", "reviewed_commit": "393b2173c4ffd39b7cc9777911f07dc0fb27e839", "draft": true, "premise_still_valid": true, "summary": "All four review items addressed at d5aeb0fce; the mechanism is untouched — engine.ts, logic-nodes.ts and validate-expressions.ts have zero change since the reviewed head, so the reviewer's 'needs no re-derivation' holds literally. (1) ADR-0087: the changeset now carries `not-required (no-migration-prescription)` with its reason; the REAL invocation is exit 0 and the same invocation on the reviewed head is exit 1, so the gate answers both ways in my hands. (2) Docs: the flows.mdx callout that told authors the executor writes the envelope verbatim and prescribed a `script`-node workaround 'today' is replaced with what landed — the three doors, the composition, and the two shapes that fault at run time. I FIXED the spec docblock rather than deferring it: it is a comment, no schema/export/type/generated artifact moves, all 18 derived spec gates plus the packages/spec-rooted roster gates are green and the 12642-test spec suite passes; the PR says so and offers to pull it out into a spec-lane card on request. (3) The whitespace-only `source` is pinned loud on both sides and added to #15430 as a second spelling of the same seam, and the §2 property is restated at its true width — registration and evaluation refuse the same set THE TWO PUBLISHED VALIDATORS DEFINE, with `ast`-only and whitespace-only faulting loudly at run time. Deliberately not closed with a trim rule of the engine's own; both pins say so in their own comments. (4) 'Exactly four things' is replaced by the composition (naming non-string `source` and non-object `meta`, and that extra keys are accepted), and the runtime publish gate is named as the third door in both the PR body and the changeset. Also merged origin/main in: the derivation warned the branch tree was 15 commits behind and that 16 of the gate scripts it derives from had changed in that range, which would have made the gate answer a well-formed statement about a tree nobody is on. PR remains a DRAFT; not undrafted, not armed, not enqueued.", "adr_0087_gate": "node scripts/check-adr-0087-registration.mjs --base origin/main --head d5aeb0fce -> EXIT 0. Verdict line: '✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.' followed by '.changeset/assignment-value-cel-envelope-executor.md [BREAKING] not-required (no-migration-prescription)'. CONTROL, same invocation at the reviewed head: --head 393b2173c -> EXIT 1, '✗ check-adr-0087-registration: 1 problem(s).' Exit codes captured by redirect-then-capture, never through a pipe. Noted for your tooling finding: I substituted the real invocation for the `--self-test` entry in the derived list this round rather than trusting the list's runnable member.", "tests": "All on d5aeb0fce. service-automation vitest 'Test Files 106 passed (106) / Tests 1275 passed (1275)' exit 0 (+1 vs round 1 — the whitespace pin). lint vitest 'Test Files 94 passed (94) / Tests 2914 passed (2914)' exit 0 (+1 — the lint-side seam pin). spec vitest 'Test Files 471 passed (471) / Tests 12642 passed (12642)' exit 0 — run because this round touches a packages/spec file. typecheck for all three packages exit 0 including check:test-typecheck (service-automation still '0 file(s) / 0 error(s)'). pnpm lint (eslint . --no-inline-config, whole repo) exit 0. The two new pins are behaviour assertions, not restatements: the service-automation one asserts registerFlow does NOT throw on the whitespace envelope and the run then fails with the located, source-carrying message; the lint one asserts validateStackExpressions reports zero issues on it. Both reproduce the reviewer's measurement independently.", "gates": "86 run · 86 exit 0 · 0 red, re-derived on the merged head. The diff grew 6 -> 8 paths (content/docs/automation/flows.mdx, packages/spec/src/automation/flow-node-expression-paths.ts) and the runnable family grew 45 -> 86: the whole docs family (check-doc-frontmatter, check-doc-route-spelling, check-docs-section-name, check-section-landing-index, check:doc-anchors, check:docs-audit-scope, check:docs-redirects, check:docs-single-h1, check:corpus-claim-drift, ...) and 18 @objectstack/spec gates (check:api-surface, check:authorable-surface, check:export-origins, check:liveness, check:docs, check:strictness-ledger, check:yaml-examples, ...) — none of which the first derivation could have named. Plus the 7 roster gates the tool marks as 'silence is not evidence in EITHER direction' because their baseline sits under a directory one of my paths is in (check-changeset-fixed, spec check:meta-url-spelling, spec check:spec-changes, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:swallow-census-controls): 7 run, 7 exit 0. No gen:schema was run, and the merge was committed before anything read the tree. ONE gate reports NOT MEASURED and is not this PR's: @objectstack/spec check:react-declaration-parity exits 1 with 'MANIFEST is not set — there is no registry side to compare against. This gate did NOT run.' — it needs objectui's sdui.manifest.json from a browser dump. It is outside the derived family and outside the flagged roster set; I ran it speculatively. CONTROL: it exits 1 identically on the shared checkout at 3bd90d415, a tree that does not contain this branch.", "mcp_calls": "14 total across both rounds (8 in round 1, 6 in round 2)", "review_items": { "1_adr_0087_marker": "DONE — marker added, real invocation exit 0, control at the reviewed head exit 1.", "2_docs": "DONE, and the spec half FIXED rather than deferred — stated on the PR with the evidence that nothing generated moved, and with an explicit offer to pull it into a spec-lane card if the reviewer would rather have it there. Deciding to touch packages/spec is the one judgement call in this round that could go the other way: my dispatch says packages/spec is not this card's surface, and I read that clause as scoped to moving the CONTRACT to fit the implementation, which a stale-prose comment is not. Cheap to reverse.", "3_whitespace_seam": "DONE — pinned on both sides, added to #15430 (comment 5543587545) as a second spelling with the mechanism spelled out, property restated in the PR body and changeset. Not closed here by a trim rule; both pins carry the reason in their own comments.", "4_wording": "DONE — composition instead of enumeration in both the body and the changeset (naming non-string `source`, non-object `meta`, and that extra keys are accepted since ExpressionSchema is not strict); the runtime publish gate named as the third door, verified at authoring-rules.ts:431-441 (`surfaces: CLI_AND_RUNTIME`, `runtimeTypes: ['flow']`, `severity: i.severity ?? 'error'`)." }, "waived_as_instructed": "Left alone per your list: the two literal copies of the MALFORMED table (but the body's 'one table' claim IS dropped — it now says each package pins its own and that there is no cross-package pin); the non-cel `source: \"\"` in the lint finding; the `assignments.digest` vs `config.assignments.digest` `where` difference; the double CEL compile per execution.", "open_questions": [], "out_of_scope_findings": [ "#15430 (updated this round): ExpressionSchema accepts two shapes no engine can evaluate — an `ast`-only envelope, and now a whitespace-only `source` (min(1) passes it, validateExpression trims and reads it as 'not authored', the CEL engine parses it untrimmed). Both pinned as loud run-time faults in this PR; disposition 1 in that card closes both at once.", "NOT filed by me, yours per your message: dispatch-gates.mjs presents check-adr-0087-registration.mjs --self-test as the runnable member and files the real --base invocation as NOT RUNNABLE LOCALLY, although the script's own usage line (:8) defaults --base to origin/main. That is how a red required gate was reported as green." ] }Note on the PR body: the platform stripped the trailing
_Generated by …_footer block during the PATCH edit, as AGENTS.md'sGitHub mutates body BYTESclause predicts for an edited body. Durable attribution is carried in the body's own prose instead; I am not re-posting the footer.Generated by Claude Code
os-warren commentedon Sep 4, 2026
LANDED —
domain:servicesexecution seat (session03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909).PR #15432 merged to
mainas954cb0bbf. Verified bygit log origin/main | grep '(#15432)'→ 1, control(#15365)→ 1.pm:dispatchedstripped; labels read back["priority:p2","domain:services"].Clause-② review PASSED at
CONTRACT_REVIEW_TIERafter two rounds. Round 1 returned CHANGES REQUIRED on four items, all of them around the mechanism rather than in it; round 2 confirmed each discharged, and the three mechanism files were byte-identical between the two heads (measured with a negative control), so the round-1 clearance of the mechanism carried literally.The four things round 1 caught, for the record
adr-0087:marker. Root cause filed as [finding] dispatch-gates offerscheck-adr-0087-registration --self-testas the runnable member and files the REAL check as NOT RUNNABLE LOCALLY — but the script's own usage line defaults--basetoorigin/main#15441:dispatch-gates.mjs:11153offerscheck-adr-0087-registration.mjs --self-testas the gate's runnable member and files the real invocation as NOT RUNNABLE LOCALLY, although the script's own usage line defaults--basetoorigin/main. A self-test's exit 0 is a zero from a command that cannot answer the question. Round 2 verified the marker's category is honestly chosen, not merely present.content/docs/automation/flows.mdxstill prescribed ascript-node workaround "today". Corrected, and round 2 drove every claim in the replacement through the real doors.{ dialect: 'cel', source: ' ' }registers cleanly, passes lint with 0 issues, then faults at run time. Pinned on both sides, folded into spec/formula:ExpressionSchemaaccepts anast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430, and the property restated at its true width rather than closed by a locally-invented trim rule.validateStackExpressionsis registeredsurfaces: CLI_AND_RUNTIME, so the runtime publish gate refuses too).Filed from round 2's hunt past the four items
#15572 — a⚠️ The card records that the reviewer's lint control there was weak (another envelope — a control that could be the answer), so the lint half needs re-establishing; the
decisioncondition accepts a CEL envelope that neither validator can see:DecisionConditionSchema.expressionisz.string(), the parse-time walk exemptsdecision, the ledger's predicate arm emits strings only — yetevaluateConditionaccepts an envelope at run time. A malformed one evaluates tofalsesilently and takes the wrong branch.registerFlowand run-time readings were driven.Still owed elsewhere
#15568 —
liveness/field.json'svalueDomainnote says the settings door "is unchanged until then", which the sibling half (#15162 / PR #15434) falsifies.Blocked-byboth halves deliberately.The ask-3 seat ruling (accept the legacy
assignments: [{ variable, value }]array as untyped legacy;AssignmentConfigSchemanot wired intoparseNodeConfigfor that shape) remains open to the maintainer's veto. It shipped as ruled, and the guarantee is structural rather than a promise — the ledger's*walk returns early on an array.Generated by Claude Code
test(lint): keep the #15742 arms in their own top-level describe
feat(service-automation): evaluate a value-role CEL envelope in the a…
fix(lint): validateStackExpressions reads an object's fields through …