Skip to content

fix(service-automation): flow write-node family refusal ends on the shared prescription sentence (#21624) - #21707

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-21624-shared-prescription
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-21624-shared-prescription

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21624
Clause-②: no

Part 3 of 3 on this card: the follow-up the seat's ACCEPT named and the spec lane's pointer handed back. Part 1, the run-time refusal, landed as PR #21649 (f40bb3217f). Part 2, the save-time FlowSchema refusal, landed as PR #21687 (a2aadab1c6). With this PR, the run-time and save-time refusals end on one sentence, and the card is complete.

What changes

  • packages/services/service-automation/src/builtin/crud-nodes.ts, storedMetadataWriteRefusal: the message keeps its node-specific lead (the node type, what it would have done and the target table, and "so the write was not run"). It now ends on STORED_METADATA_BODY_PRESCRIPTION, imported from @objectstack/spec/kernel beside isStoredMetadataBodyObject, which the file already imported from that subpath. Its docblock gains one paragraph that names the shared sentence.
  • The only wording change an author sees is the elevation clause. It was "Elevation (runAs: 'system') does not change this." and is now "Elevation (runAs, a system context) does not change this." The rest of the closing sentence was already byte-identical to the constant.
  • write-nodes-stored-metadata-family-refusal.integration.test.ts: the two message-text assertions (the save-time issue message and the run-time run error) used to check for a restated fragment, toContain('the metadata protocol'). Each now asserts that the message ENDS on the imported constant, through a small closingPrescriptionOf helper. Every refusal, no-write, code and identity assertion is unchanged, and none is deleted.
  • A patch changeset for @objectstack/service-automation.

What is unchanged: the set of refused writes, the PERMISSION_DENIED code, the guard classification (a fault edge does not route it), and every non-family write.

The dispatch's assumptions, as measured

  1. The constant's wording is true for every run-time caller. The refusal runs before any identity is resolved, and the flow engine elevates in one way only: runAs: 'system' (resolveRunDataContext gives isSystem: true). "Elevation (runAs, a system context) does not change this" therefore holds for each run-time path. No sentence becomes false, and the constant was not touched.
  2. The @objectstack/spec/kernel subpath already reaches this package's build and tests. @objectstack/spec is a declared dependency, and crud-nodes.ts already imported from @objectstack/spec/kernel. The vitest config has no source alias for @objectstack/spec, so tests reach it through exports (the spec package's built kernel entry). The source module and the pin read the same object. That pair is already recorded in check-test-source-alias.mjs's KNOWN_UNALIASED_TEST_IMPORTS for this package, and check:test-source-alias exits 0. The built dist/index.js carries 2 hits for the constant and 0 for the old clause, and check:dual-build-cjs-loads exits 0.
  3. The pins import the constant. No assertion was deleted (see above).
  4. Ablation: see below. Predicted and observed agree.
  5. The runtime body boundary's private PRESCRIPTION (packages/runtime/src/stored-metadata-body-boundary.ts, domain:cli): it is byte-identical to the shared constant (211 bytes each, compared programmatically). It is a copy, not an import. Not edited; see the acceptance notes.

Verification (all at 1b9252e0f2 unless stated)

Every heavy run went through scripts/pm/os-verify-lock.sh, and each verdict below is read from its VERDICT command-exit line.

  • pnpm --filter @objectstack/service-automation test (vitest run): Test Files 170 passed (170), Tests 2098 passed (2098), exit 0. The same reading was taken at d589cd4418, before origin/main (8843505d91, objectql only) was merged in and the closure rebuilt.
  • The pin file alone, at d589cd4418: 17 passed (17).
  • pnpm --filter @objectstack/service-automation typecheck: exit 0, and check:test-typecheck OK (0 files in the ledger). tsc --noEmit --listFiles -p tsconfig.json lists the pin file (1 hit) and crud-nodes.ts (1 hit).
  • Ablation, with the direction predicted before the run. The mutation appends one word to the run-time closing sentence, inside the sentence. The anchor was + STORED_METADATA_BODY_PRESCRIPTION, in crud-nodes.ts, and the replacement calls .replace('change this.', 'change this ABLATIONMARKER.') on it, applied with scripts/ablation-replace.mjs.
    • Prediction: 1 file red. 9 tests red, every case that goes through expectRefused (6 node x identity cases without the security plugin, 3 node cases with it), each failing first on the run-time "ends on the family's prescription" assertion. 8 green. The save-time assertion never red. Package total: 9 failed / 2089 passed.
    • On disk: the anchor went 1 to 0 and the replacement 0 to 1, and the blob changed from 86ed89180f to 76faa10823. The subject is reached through relative src imports (../plugin.js, then ./builtin/index.js), so no dist rebuild or preflight applies.
    • Observed, full package suite: Test Files 1 failed and 169 passed (170); Tests 9 failed and 2089 passed (2098). All 9 AssertionErrors are the run-time prescription assertion, and 0 are the save-time one.
    • Restore, as reported by the tool: the blob after restore is 86ed89180f and equals HEAD's, and git diff HEAD is empty. An own trap (git checkout HEAD -- on the absolute path, then a hash comparison) re-confirmed it with 0 diff lines, and porcelain was empty.
  • Lint, as a proven narrowing (pnpm lint itself belongs to CI):
    • Population, read from eslint's own config: 2 of the 3 changed paths are linted. For the changeset, eslint answers "File ignored because no matching configuration was supplied".
    • Count, from --format json --no-inline-config: 3 results, with 0 errors and 0 warnings on the 2 TS files.
    • Invariance: eslint.config.mjs sets no parserOptions.project and no projectService, so the linting is not type-aware and this diff cannot move an untouched file's verdict.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) derives 64 commands (30 pnpm, 34 node), the same list before and after the merge.
    • All 64 were run. 63 exited 0 on the first pass.
    • check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: unbuilt packages, nothing measured). After a full turbo run build (72/72) it exited 0, measuring 106 entries across 66 packages.
    • --ran reconciliation: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. check:nul-bytes exited 0, and a control-byte scan of the 3 changed files found none.
  • Docs and skills: content/docs/** (outside releases/) and skills/** hold 0 copies of the old run-time sentence. The positive control was the same patterns on crud-nodes.ts before the edit (3 hits), and the pathspec control was create_record in content/docs/automation/flows.mdx (7 hits). Nothing to edit.

Acceptance notes

  • The runtime body boundary's PRESCRIPTION is a second copy of the sentence, in packages/runtime/src/stored-metadata-body-boundary.ts. Today it is byte-identical to STORED_METADATA_BODY_PRESCRIPTION, so no author reads two wordings. But the constant's own docblock says to import it rather than restate it, and a later rewording would leave this copy behind. That file already imports isStoredMetadataBodyObject from @objectstack/spec/kernel, so the change is one import. It is another lane's file (domain:cli) and is not edited here. Carrier: none.
  • The spec's ADR-0087 semantic migration prescriptions for the hook and flow refusals restate the same elevation clause as frozen text. They are in the spec lane and match the shared wording.
  • The unreleased part 1 changeset describes the refusal in prose and does not quote the elevation clause, so it is not made false. It is not edited, since it is not this PR's changeset.

Generated by Claude Code

claude added 2 commits October 4, 2026 07:45
…hared prescription

storedMetadataWriteRefusal keeps its node-specific lead and ends on
STORED_METADATA_BODY_PRESCRIPTION from @objectstack/spec/kernel, the one
sentence FlowSchema's save-time refusal of the same node ends on. The
message-text pins read the imported constant instead of restating it.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 8 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via getItem (sdk, the bare tail of client method meta.getItem, bound to GET /api/v1/meta/:type/:name), meta.getItem (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name))
  • content/docs/api/declarative-endpoints.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/api/error-catalog.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/api/wire-format.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/concepts/metadata-lifecycle.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/permissions/capabilities.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/plugins/adding-a-metadata-type.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/protocol/kernel/metadata-service.mdx (via meta.saveItem (sdk, the route ledger binds it to PUT /api/v1/meta/:type/:name), saveItem (sdk, the bare tail of client method meta.saveItem, bound to PUT /api/v1/meta/:type/:name), /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/protocol/objectql/state-machine.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/protocol/objectui/index.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/ui/doc-pages.mdx (via getItem (sdk, the bare tail of client method meta.getItem, bound to GET /api/v1/meta/:type/:name))
  • content/docs/ui/forms.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/releases/v17/17-1.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/releases/v17/17-2.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/releases/v17/17-3.mdx (via deleteItem (sdk, the bare tail of client method meta.deleteItem, bound to DELETE /api/v1/meta/:type/:name), meta.deleteItem (sdk, the route ledger binds it to DELETE /api/v1/meta/:type/:name), meta.saveItem (sdk, the route ledger binds it to PUT /api/v1/meta/:type/:name), saveItem (sdk, the bare tail of client method meta.saveItem, bound to PUT /api/v1/meta/:type/:name), /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/releases/v17/17-5.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))
  • content/docs/releases/v17/17-6.mdx (via /api/v1/meta/:type/:name (route, a path literal in storedMetadataWriteRefusal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 16d241a6af00be4acce9883190fc333a5f560825 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c3440907644b0bfe553c2c80c184ec7ca2e850c — the merge of head 1b9252e0f2525ea3c6b2e07e0f45233e370b54bf into base 16d241a6af00be4acce9883190fc333a5f560825, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4c3440907644b0bfe553c2c80c184ec7ca2e850c && git checkout 4c3440907644b0bfe553c2c80c184ec7ca2e850c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 16d241a6af00be4acce9883190fc333a5f560825 1b9252e0f2525ea3c6b2e07e0f45233e370b54bf && git checkout -B drift-repro 16d241a6af00be4acce9883190fc333a5f560825 && git merge --no-ff 1b9252e0f2525ea3c6b2e07e0f45233e370b54bf

node scripts/docs-audit/affected-docs.mjs --json 16d241a6af00be4acce9883190fc333a5f560825

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 16d241a6af00be4acce9883190fc333a5f560825 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 08:58
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 08:58
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 5ac2ba1 Oct 4, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21624-shared-prescription branch October 4, 2026 09:33
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 37190736783 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Built console still carries the PUBLISHED @objectstack/spec.
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tests tooling

Projects

None yet

2 participants