fix(service-automation): flow write nodes refuse a stored-metadata family target (#21624) - #21649
Conversation
…mily target (#21624) create_record, update_record and delete_record refuse a target in the stored-metadata family (isStoredMetadataBodyObject) before they resolve their filter or field values and before any engine write, under either run identity, as a guard refusal carrying PERMISSION_DENIED with a prescription naming the metadata protocol. This also closes the write nodes' filter evaluate exit on the family tables. Rider: the NodeExecutionResult.code docblock names every executor that sets code today. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…snapshot read (#21624) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 34 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6e7baedd5226b3b0902e08fbb62a353c51f10168 && git checkout 6e7baedd5226b3b0902e08fbb62a353c51f10168
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 15fe567c9c74e088684944094aa686b9bd3b386c afbc55b76463ee1f894193d551d1565c6e27dc76 && git checkout -B drift-repro 15fe567c9c74e088684944094aa686b9bd3b386c && git merge --no-ff afbc55b76463ee1f894193d551d1565c6e27dc76
node scripts/docs-audit/affected-docs.mjs --json 15fe567c9c74e088684944094aa686b9bd3b386c
|
Part of #21624
Clause-②: no
This PR carries the card's run-time half. The save-time half is not in this package; it is named below for the seat as an open question, and #21624 remains open for it.
What this changes
A flow's
create_record,update_recordanddelete_recordnodes no longer write the stored-metadata family (the current metadata table and its version history, judged by the family's own predicateisStoredMetadataBodyObjectfrom@objectstack/spec/kernel). Triage's ruling on the card, "refuse at the node", applies the reason of #21520's ruling A (the family has one writer for app-authored work, the metadata protocol) to flows.crud-nodes.tsgainsstoredMetadataWriteRefusal(nodeType, objectName). For a family target it returns a guard refusal (refuseNode, the node's existing channel) that carries the standard catalog'sPERMISSION_DENIED. The message names the metadata API and the metadata protocol as the way to change metadata, and says that elevation does not change this. Any other object getsundefined.objectNamecheck. That is before the filter is interpolated or the erased-condition guard runs, beforefieldsis resolved, and before the data engine is looked up. So a refused node answers the same whatever its filter or payload names, and nothing is computed or written. This also shuts the write nodes' evaluate exit: theirfilteris never run against a family table.objectNamestring the executor hands the engine. The write executors do not interpolateobjectName, which was measured: a{token}target reaches the engine as the literal template.NodeExecutionResult.codedocblock inengine.tsnamed one executor as "the only executor that sets it today".get_record(PR fix(service-automation): the flow get_record node refuses a filter that evaluates the stored-metadata family, with the data door's refusal (#21623) #21641) and now the three write nodes set it too. The sentence now names each executor and the code it sets.Files:
crud-nodes.ts,engine.ts(the docblock rider, one sentence), a new pin file and a changeset. There is nometadata-protocol,packages/specor lint edit.Census, taken before any edit
The census asked which shipped flow has a
create_record,update_recordordelete_recordnode whose target is a family table. A read-only scanner found every write-node literal (type: 'WRITE_NODE'in TS, JS, JSON, YAML and MD) and read theobjectName/objectkey from the same node object. A literal target was classified as family or non-family. A non-literal target (a variable, a template, or none) was listed and read by hand.packages/**, non-test (96b0e3108a)examples/**, non-testgit grepfinds)skills/**content/docs/**9466837registerFlowcallers are the flow loader and the/automationwrite doors; neither builds a flow of its own.objectNameraw and never interpolate it, which the reproduction below confirms. So a write node's target is always the literal in its config. The census's non-literal column covers TS-built configs.delete_recordand oneupdate_recordaimed at the two family tables gave 2 of 2 family hits. The same scanner, run overget_recordin test files, listed the sibling read pins' parameterized family targets as non-literal (8 hits). The family names themselves occur in 383 non-test package files, for example the platform's own list views inmetadata-core.Census result: zero. No shipped flow and no platform flow writes the family through a data node, so the refusal lands over no platform writer.
Reproduction on
mainbefore the fix (by class)The composition was
ObjectKernel,ObjectQLPlugin, the realAutomationServicePlugin, anddriver-sqlon better-sqlite3:memory:. Every case was run without the security plugin and with the realSecurityPlugin(the default permission sets). No stored value is recorded here.runAs: 'system'andrunAs: 'user', changed the table (12 of 12). That includesdelete_record, which was not measured on the card.runAs: 'system'changed the table in 6 of 6 cases (three nodes, two tables), since the elevated write skips the middleware.runAs: 'user'was refused in 12 of 12 cases, for both a rank-and-file member and a platform administrator. The refusal came from the middleware's ADR-0103 engine-owned write guard. It surfaced as a routable runtime failure with no code, and the table was unchanged.update_record(multi: true, system identity) whose filter read the stored body column acted on the row (acted: 1) for a matching guess and on nothing (acted: 0) for a non-matching one.objectName: '{record.target}') is not interpolated: the engine was asked for the literal template, answered "not found", and the family table was unchanged.The code: why
PERMISSION_DENIEDThe dispatch asked which code the generic data door answers for a direct family write, so it could be reused. Two answers were measured:
/data/:objectwrite verbs on either table answer 405OBJECT_API_METHOD_NOT_ALLOWED. That answer comes fromapiAccessDenialFromEnableover the tables'apiMethods: ['get', 'list']. Measured throughapiExposureDenialReason, the spec helper that function wraps: create, update and delete givemethod-not-allowed.createData/updateData/deleteData) answers a non-platform principal withPERMISSION_DENIED/ 403 in a secured composition, for both member and administrator. The system context is admitted, since it is the platform's own path.The node carries
PERMISSION_DENIED. The 405 is the REST exposure gate, and its own docblock scopes it to the external API: "Internal callers (hooks, flows, raw objectql) are unaffected". The 403 is the door's answer to the condition the node refuses: a principal that is not the platform may not write these tables. It is also the code that ruling A's body-write boundary (stored-metadata-body-boundary.ts) carries for the same rule. No code is minted, andPERMISSION_DENIEDis a standard catalog member.The save-time half is not in this package (open question for the seat)
Triage's third pin asks that a flow naming a family target statically be refused at save. Read from the code, the save doors judge a flow as follows:
/metasave door (Studio, REST/meta, MCP authoring) runssaveMetaItem, which does three things. It canonicalizes the flow through the automation engine; a throw there is caught and the raw body is kept. It then runs the per-typeFlowSchemasafeParse. Finally, the runtime authoring gate inmetadata-protocolruns the@objectstack/lint/runtimerules.registerFlowis not on that path. The engine registers the stored row afterwards, on the publish rebind.FlowSchema.parseinpackages/spec.flow-node-config-refusals.tsdescribes itself as "the one judgeFlowSchema.parse,AutomationEngine.registerFlow(which parses first) andobjectstack validateshare".HookSchema(data/hook.zod.ts,refuseBodyOnStoredMetadataTarget).So the save-time refusal belongs outside
service-automation, and per the claim nothing there is edited here. The position and the edit it needs are in the report on the card, as an open question for a claim revision. The run-time refusal alone shuts the reach.Pins
New file:
packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts, 17 cases on the composition above.insert/update/deleteis never called on a family table, and the table snapshot is unchanged;PERMISSION_DENIEDon{$error.code}and on atry_catchregion's error variable.runAs: 'system', underrunAs: 'user'as a member, and underrunAs: 'user'as a platform administrator. The same assertions apply.PERMISSION_DENIED/ 403.update_recordand adelete_recordwhose filter reads the stored body, with a matching and a non-matching guess, give the same refusal text, no engine write and an unchanged table.faultedge on the node, the run still fails and the handler never runs, for all three nodes.Ablation
The fix was committed first. The direction was predicted before the run. With the refusal's effect removed, the 12 refused-case pins would go red, because the write runs and succeeds. The 5 controls would stay green: the data door control, the two variable-target cases (the refusal never applied to them) and the two non-family cases.
scripts/ablation-replace.mjsreplaced the family test at the head ofstoredMetadataWriteRefusalwith an always-undefinedreturn carrying a marker. The anchor went from 1 to 0 and the marker from 0 to 1, and the file's blob changed. The subject is reached through a relativesrcimport, so nodistrebuild or preflight applies.git diff HEADempty. A bash trap (git checkout HEAD --on the absolute path, then a blob comparison) re-confirmed it with 0 diff lines.git status --porcelainshowed 0 lines and the marker grep 0. The re-run gave 17 passed (17).1e7d538944, at233d3206e7and atafbc55b764(the final head), with identical readings each time.Verification
Every reading is at HEAD
afbc55b764. That head mergedorigin/mainat15fe567c9c(three commits, none in this package) and then rebuilt the tree (turbo run buildexcluding docs, 72 of 72 tasks). Every heavy run went throughscripts/pm/os-verify-lock.sh.pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2(the whole suite, the new pins included): Test Files 169 passed (169), Tests 2095 passed (2095), VERDICT command-exit 0.pnpm --filter @objectstack/service-automation typecheck: VERDICT command-exit 0, andcheck:test-typecheckis OK.tsc --listFilesshows the new pin file in bothtsconfig.jsonandtsconfig.test.json.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) derives 65 commands atafbc55b764(31 pnpm and 34 direct node), with no stale-tree warning.--ranreconciles them: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN (exit 0).check:dual-build-cjs-loadsmeasured 106 entries across 66 packages on the built tree. At the first pass (1e7d538944, unbuilt tree) it answered PREREQUISITE NOT MET (exit 3), which measured nothing.check:query-options-erasurewas red at that first pass: the pin file's snapshot read added one untyped engine-options site to the test surface (236 to 237). The read was typed in233d3206e7, and the gate holds at 236.origin/main(a workflow file and the SDUI manifest record changed). After the merge, the same 65 commands derive.check:nul-bytesexited 0. No turbo-driven gate left anAGENTS.mdblock, andgit status --porcelainwas empty after each pass.pnpm lintitself is CI's):crud-nodes.ts,engine.tsand the pin file). For the changeset, eslint answers "no matching configuration".--format jsonwith--no-inline-config: 0 errors and 0 warnings on the 3 linted files.eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move the verdict on an untouched file.Docs
content/docs/**(outsidereleases/) andskills/**were searched for flow data nodes writing system or metadata tables. No sentence states that a write node may target them, so none is made false and nothing is edited. Notes:content/docs/automation/flows.mdxhas an "In practice that is …" list. It does not name this refusal (nor the read-node one), so the list is incomplete, not false.{$error.code}row there says "e.g.create_record'sDUPLICATE_RECORD", which stays true.Acceptance notes
.changeset/21623-flow-read-node-evaluate-refusal.mdsays "The write nodes are unchanged", which was true of that change. Both changesets compile into the same release, and this PR's changeset states this change. This PR does not edit a changeset it did not add. Carrier: none.registerFlowwas deliberately not given a second static check. It parsesFlowSchemafirst, so the save-time judge, wherever the seat places it, reaches it without a copy here.Generated by Claude Code