Skip to content

feat(automation): a flow's credentials live in a write-only channel on the secret seam, not in its stored definition (#20790) - #21377

Merged
objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-20790-flow-hook-secret-seam
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-20790-flow-hook-secret-seam

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20790
Clause-②: yes (widening)

This PR carries out ruling record 5942356310 (letter A, R2 and C1, the maintainer's 「同意264」) under claim 5935167060 and its revision 5942559287. It names classes and positions only: no request, header, route, field path or value.

Cross-lane files (named before the change list)

  • domain:engine
    • packages/metadata-protocol/src/protocol.ts: the per-type credential-channel registration, the save door's channel step (after the carry-forward, before the put), the publish gate's read of held positions, and the rollback and revert callers that pass the strip.
    • packages/metadata-protocol/src/sys-metadata-repository.ts: the restore verb gains a body-derivation option shaped like the promote verb's (R2).
  • domain:spec: packages/spec/src/system/constants/platform-object-names.ts, one registry line.
  • domain:cli
    • packages/runtime/src/flow-clone.ts: the C1 refusal.
    • packages/runtime/src/domains/automation.ts: the clone handler consults the refusal. This file is in claim 5935167060 but not in revision 5942559287's list (see Acceptance notes).
    • Two runtime pins.
  • Shared harness: packages/qa/dogfood/ (one pin, one dev dependency, one source alias) and pnpm-lock.yaml.
  • Generated ledgers: the platform-object tenancy census, the tenant-audit census page and counts file, and the engine-double-contract ledger. Each was regenerated by its own --write.

What changed

1. A write-only channel on the existing secret seam (service-automation).

  • The new platform object sys_flow_credential holds one row per credential position of a flow, per lifecycle state (draft or active).
  • Its one value field is secret-typed: the engine encrypts it through the host crypto provider, masks it on every read, and dereferences it only through the privileged resolver. No second secret mechanism and no per-door redaction were added.
  • The object is private, closed to the generic data door, untracked and unsearchable. Its unique key is a fixed-width digest of the position.
  • FlowCredentialChannel handles five operations:
    • store: explicit values go in; absent keeps; the cleared form deletes; a vanished position is dropped.
    • strip: takes credentials out of a body.
    • held positions: what the runtime gate reads as present.
    • promote: draft to active, on publish.
    • prune: on delete.
  • A draft save never rotates the live credential. Publishing the draft promotes it.

2. The save door stores the body the channel returns (metadata-protocol).

  • registerCredentialChannel(type, channel) registers a channel. saveMetaItem runs it after the carry-forward and before the put, so the stored row, every new history row and the content hash carry no credential.
  • The runtime authoring gate reads the channel's held positions as present, both on an active save and when a draft is published.
  • restoreVersion takes deriveRestoredBody. Rollback and revert pass the strip. A restore past the move therefore never puts a credential back at rest, and the channel keeps its current one. No new history copy is written.

3. Credentials are read at use time (service-automation, trigger-api).

  • An inbound binding carries a resolver that reads the hook secret on each verification, so a rotation applies to the next post without re-arming.
  • If a held secret cannot be read, the post is answered 503 SERVICE_UNAVAILABLE. It is never verified against nothing, and nothing is enqueued.
  • The outbound http node resolves a held signing secret at execution. If it cannot read the secret, it refuses the node, so nothing is sent unsigned. The cleared form still sends unsigned on purpose and never asks the channel.
  • For a packaged flow, a channel row wins at verification and the literal is the fallback (Q3 A).

4. C1: the clone door refuses a credential-holding source (runtime).

  • The door refuses when the source holds a credential at any position, whether as a literal (a packaged flow) or held in the channel, the outbound signing secret included.
  • The answer is 409 RESOURCE_CONFLICT, names the classes, and gives Q2 A's prescription: author the copy as a new flow with its own secret.
  • Accepted cost, stated in the changeset: a packaged inbound flow can no longer be cloned in one step.

5. A one-time move with a receipt (service-automation).

  • At kernel ready, stored flow rows that still carry a credential are saved again through the save door itself.
  • Each moved flow gets one rotation notice in the log, naming the flow and its classes and never a value (Q1 B: rotate, don't scrub).
  • With no provider, the run defers and writes nothing. A row that fails to move logs at error and says the row still carries the credential in cleartext.
  • The run is recorded in sys_migration as flow-credential-channel, with counts and names only.
  • History and audit rows are not rewritten. Packaged flows are not moved (Q3 A).

6. No provider means no write. With no crypto provider, a save that would land a credential is refused (503) before any row is written.

7. Spec and docs.

  • Spec: one registry line.
  • Docs: the flows page and the lifecycle page's clone row each had one sentence that this change made false; both are corrected.
  • Changeset: minor for five packages. It carries the rotation instruction and the accepted cost, and the ADR-0087 gate reads it as non-breaking.

Evidence (head 417ba1fa6)

Pins (the ruling's list plus the card's four and Q4's outbound set):

  • A channel write and its masked reads.
  • Draft-to-active promotion.
  • R2: a rollback past the move.
  • C1: refusal for a literal-held source, a channel-held source and an outbound-held source.
  • The administrator engine read (the reader the MCP stdio transport serves from) after the move.
  • No provider means no write.
  • No read surface serves the value.
  • The inbound door verifies after the move and after an edit-and-republish.
  • An explicit rotation replaces the credential.
  • A packaged flow is untouched.
  • Outbound signing reads the held secret.
  • Delete drops the credential.

The end-to-end pin is packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts (8/8).

Every negative pin was ablated:

  • A1 to A18 ran at a38db79ec through scripts/ablation-replace.mjs. Each anchor hit, each pin turned red, and after each restore the tree read clean against HEAD. Red counts ranged from 1 to 6 per ablation, across the channel, trigger, http-node, migration, clone and protocol pins.
  • D1 (the dogfood pin) ran at 457f43476:
    • Mutated build: the marker was present in dist/ by preflight, and 6 of 8 tests went red. Tests 6 and 7 stayed green, as expected, because they do not read the ablated step.
    • Restore: preflight --absent passed, 8/8 green, and the diff against HEAD was empty.

Suites at 80b4647b2, each in the foreground under the shared verify lock:

  • service-automation: 166 files, 2051 passed.
  • metadata-protocol: 2 shards, 202 files plus 3 skipped; 2985 passed, 19 skipped.
  • trigger-api: 2 files, 30 passed.
  • runtime local project: 3 shards, 304 files; 4335 passed, 11 skipped.
  • spec local project: 2 shards, 598 files; 17512 passed, 1 todo.
  • Dogfood pin: 8/8.
  • Typecheck (service-automation, metadata-protocol, trigger-api, runtime, dogfood) and spec tsc: all exit 0.

Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 129 commands at 80b4647b2, and all 129 exited 0. The --ran reconciliation answered: 129 derived, 129 run, 0 NOT-MEASURED, 0 UNRUN.

Declared to CI: the full dogfood suite, the runtime repo project, and repo-wide lint.

Contract review round 1

The contract review of record found one wrong judgment: Q3 A at the inbound door. With a literal start-node secret, the hook reader asked the credential channel on every post, and the channel throws when it has no reachable store. A packaged inbound flow on a composition with no data engine therefore armed on its literal and was answered 503 on every post.

Commit 84d3d297a fixes it in the http node's shape, so both doors read one rule:

  • With a literal, the reader asks the channel only when the channel's index holds that position. Otherwise it answers the literal without touching the channel.
  • A held secret that does not come back still rejects, so the post is answered 503 and is never verified against the literal. The channel's own read keeps its throw.
  • The index is per process. A row written after its last refresh (boot, kernel ready, metadata reload, or a channel write in this process) loses to the literal until the next refresh, exactly as at the http node.

Pins:

  • (a) The real channel with no reachable store, and a packaged literal inbound flow: the reader answers the literal, and a correctly signed post through the real trigger answers 202.
  • (b) The control: the channel holds the position, then its store becomes unreachable. The reader rejects, and the post answers 503 whether it is signed with the literal or with the held value.
  • (c) The existing Q3 A pin (a held row wins over the literal) stays green.

Ablations at 84d3d297a. Each anchor hit; each run rebuilt and the dist preflight found the marker; each restore was proven by the file equalling its HEAD blob, the --absent preflight passed, and both pins went green again:

  • E1, the holds gate removed: pin (a) went red in service-automation (1 of 17) and in dogfood (test 9: 503 where 202 was expected).
  • E2, a held but unreadable secret falling back to the literal: pin (b) went red in service-automation (1 of 17: the reader answered the literal instead of rejecting) and in dogfood (test 10: 202 where 503 was expected).

At 417ba1fa6, after merging origin/main:

  • service-automation: 166 files, 2053 passed.
  • trigger-api: 2 files, 30 passed.
  • The dogfood pin: 10/10.
  • service-automation and dogfood typecheck: exit 0.
  • The full gate union: 130 derived, 130 run, 0 NOT-MEASURED, 0 UNRUN.

The size is now 3646 changed lines (+3557 / −89), of which 1,678 are added test lines.

Acceptance notes

  • Size. 3532 changed lines (+3443 / −89, 36 files) against the ruled band of 2300 ± 500. That is over the band but under 5000, and 1,578 of the lines are added test lines. There is no split.
  • Premise. The premise was re-measured on main and still holds: the stored row and the history row carried both credentials in cleartext, and an administrator's engine read returned them. The MCP stdio door had already stopped serving them by the time of this build (the fix(mcp)!: the MCP stdio engine-only reader joins the stored-metadata-body family (exit one) #21228 change). [security] Stored-metadata-body family: two exits #21120 did not reach. An engine-only door serves an administrator credential material in cleartext, and the data door serves a content hash computed over the withheld credentials #21207 remains open. For flows only, this PR also removes the credential from what that card's checksum exit covers.
  • File surface. packages/runtime/src/domains/automation.ts is outside revision 5942559287's list and inside claim 5935167060. The clone handler there is where the C1 refusal is consulted.
  • Package duplication. Duplicating a package that holds an inbound flow whose secret the channel holds is now refused by the runtime authoring gate, because the copy holds no secret. This is consistent with C1: a copy never shares a secret.
  • Inert migration mode. In inert mode, the stored re-save tool refuses a flow row that still carries a literal when no provider is registered. This is the no-provider rule, applied at that door.
  • Legacy drafts. A legacy draft that still carries a literal, published while no provider is registered, is refused (503) for the same reason.
  • Channel keying. The channel keys by flow name and state, env-wide like the engine's flow map. Stored rows of the same name in two packages therefore share one slot.
  • Durability list. The receipt write is not on the durability-critical callee list.
  • Write order. The channel write precedes the stored put. If the put fails, the channel is ahead of the row until the next save. No credential is exposed in that window.
  • Presence index. The engine's check for whether a flow holds a credential reads an in-process index. The index is refreshed at boot, at kernel ready, on metadata reload, and on every channel write in that process. The value itself is always read live.
  • Stale derivation. origin/main moved at least 10 commits after the gate derivation at 80b4647b2. One derivation input changed (a release script, outside this diff), and a test merge against current main is clean.

Generated by Claude Code

claude added 11 commits October 2, 2026 04:27
…he secret seam

Channel object, the save-door step, the gate reads, the restore strip,
the engine's verification-time resolution, the clone door refusal and the
one-time migration. Tests follow.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…body shape in the pin

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…nt the new object and write sites

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…bles meet the engine contract gates

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…pin's find double refuses operators

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…credentials now live

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…registration; the test crypto fake meets the provider contract

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…f a credential-holding source

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:system tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/metadata-protocol, @objectstack/dogfood, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec, @objectstack/trigger-api, touching 130 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts, packages/qa/dogfood/vitest.config.ts, packages/services/service-automation/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

65 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 85986144c2ef6f379955137677c5cbfb00e194d2.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts, packages/qa/dogfood/vitest.config.ts, packages/services/service-automation/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: isSystem (5 routes)
  • 26 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 144 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 85986144c2ef6f379955137677c5cbfb00e194d2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fe35e66ada5e2c9e828ce9ea7029ca1578f02a95 — the merge of head 417ba1fa6fc6399a6bc077459c45d239542245e1 into base 85986144c2ef6f379955137677c5cbfb00e194d2, 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 fe35e66ada5e2c9e828ce9ea7029ca1578f02a95 && git checkout fe35e66ada5e2c9e828ce9ea7029ca1578f02a95
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 85986144c2ef6f379955137677c5cbfb00e194d2 417ba1fa6fc6399a6bc077459c45d239542245e1 && git checkout -B drift-repro 85986144c2ef6f379955137677c5cbfb00e194d2 && git merge --no-ff 417ba1fa6fc6399a6bc077459c45d239542245e1

node scripts/docs-audit/affected-docs.mjs --json 85986144c2ef6f379955137677c5cbfb00e194d2

⚠️ 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 85986144c2ef6f379955137677c5cbfb00e194d2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 2, 2026 09:25
…through the metadata API; name both triggers of the one-time move

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…only for a position it holds

The inbound reader now has the http node's shape: with a literal start-node
secret it asks the channel only when the channel's index holds that position,
and otherwise answers the literal without touching the channel. A held secret
that does not come back still rejects and is never verified against the literal.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 12:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

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

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

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

  • Test Core (4/6) — 失败步骤: Run this shard's tests

    @objectstack/rest:test:  FAIL   local  src/import-template-route.test.ts > the `*` agrees with the engine: starred exactly when the import door refuses a blank > for every shape of default the engine 
      ↳ 失败原因: @objectstack/rest:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时(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,按失败测试文件聚合):

  • src/import-template-route.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

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

分诊清单:

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Queue failure triaged: not this PR's; one re-queue · domain:services seat 2 · session_01DiCSbmJrkzNhuEAier4VoJ · 2026-10-02T12:53Z

  • Failing check: queue build 37006699795, Test Core (4/6), @objectstack/rest src/import-template-route.test.ts › "the * agrees with the engine … for every shape of default". Test timed out in 5000ms: a timeout, not an assertion.
  • Why it is not this PR's:
    • This PR does not touch packages/rest, and that test file has not changed since 88b484e00.
    • rest depends on metadata-protocol, which this PR does change. That change is the credential-channel registration, the save-door store call and the restore derivation, and each runs only when a type registers a channel. Only service-automation registers one, and the rest test's engine does not load it.
    • On the PR head 417ba1fa6, the PR-side CI ran the affected Test Core shards (rest included, as a dependent of metadata-protocol) and every shard was green.
    • The queue's own triage reports this signature as a first occurrence in 24h.
  • Action: one re-queue (auto-merge re-armed). This is the single permitted re-run for a timeout on an untouched test. If the same test fails again in the queue, it is treated as real and investigated, with no second blind re-queue.

Generated by Claude Code · https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 96a9719 Oct 2, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20790-flow-hook-secret-seam branch October 2, 2026 13:28
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ved and compared only in keyed form, never copied, never evaluated (objectstack-ai#21207) (objectstack-ai#21436)

Fixes objectstack-ai#21207
Clause-②: yes (narrowing)

Exit two of objectstack-ai#21207, under the maintainer's ruling B (`5942670275`) and
its execution forks A / A / A (`5950183039`). One PR closes the whole
hash-serving exit family enumerated in the exit-two report `5946577002`
(members 1 to 13), plus one member this PR's own measurement found (14,
below). Exit one already landed as objectstack-ai#21228.

The stored content hash of a metadata body stays the canonical hash at
rest: the repository contract, its producers, the filesystem layer and
the parent links are untouched. What changes is what a caller is given
and what a caller may evaluate:

- **Served** — every door that hands the hash out hands out a keyed
digest of it: the crypto provider's, or, while no provider is
registered, one under a process-scoped ephemeral key. The one exception
is the MCP stdio reader, which omits the two hash columns on a host with
no provider.
- **Inbound** — every door that takes a version token back compares it
in keyed form against the current stored head and hands the stored value
to the repository's own lock. A raw stored hash and a stale token are
refused with `409 METADATA_CONFLICT`. With no provider, a token this
process served is accepted, and an empty, withheld, raw or stale token
is refused the same way.
- **Evaluated** — filter, sort and group on the two stored content-hash
columns are refused with `400 INVALID_FIELD` before the engine, at the
data door, the MCP stdio reader and the analytics door. A data-door
search over the two stored-metadata tables no longer scans them.
- **Copied** — the ledger snapshot and diff, the activity copy and the
decision-audit note carry no hash. `os migrate audit-metadata-bodies`
(dry run by default, idempotent) now also rewrites the copies already at
rest. The version history stays the lineage.

Disclosure discipline: this body names classes, doors, roles, codes and
statuses only.

## The exit family, member by member

| # | Exit (class) | Door(s) | Disposition |
|:--|:--|:--|:--|
| 1 | save receipt version token | `/meta` save door, runtime dispatcher
save door | keyed at the protocol; both transports inherit it |
| 2 | publish receipt version token | `/meta` publish door | keyed |
| 3 | package batch-publish version tokens | package publish door |
keyed per element (the stored value stays internal) |
| 4 | rollback receipt version token | `/meta` rollback door | keyed |
| 5 | history read: event hash and parent hash | `/meta` history door |
keyed per event |
| 6 | conflict refusal: text and attributes | save, publish, rollback,
reset doors | keyed values or none |
| 7 | decision-audit note of a conflict | written by the protocol,
served by the `/meta` audit door and the data door | names no hash; a
side is `(withheld)` or `null` |
| 8 | the two stored content-hash columns on both stored-metadata tables
| data door get and list | keyed |
| 9 | evaluate shapes on those columns | data door filter, sort, group,
and search | `400 INVALID_FIELD`, naming the usable columns |
| 10 | MCP stdio engine-only reader | bridge query, get and aggregate;
the record resource | keyed; group, filter and sort refused |
| 11 | audit ledger copies | plugin-audit writer | the two columns are
dropped at write time; at rest via the migration |
| 12 | activity copies | plugin-audit writer | same as 11 |
| 13 | analytics members on those columns | analytics door | `400
INVALID_FIELD` in either role |
| 14 | the version history's change note | history read, data door, MCP
stdio reader, copies | see below |

**Member 14, found by the after-measurement.** A draft promotion that
stated no message of its own recorded the draft's stored hash in the
history row's change note. That note was served by the history read, the
data door and the MCP stdio reader, and the audit writer copied it. The
fix:

- The publish door now always states a hash-free message.
- A note written before this change is served with each quoted hash in
keyed form (under the process key while no provider is registered). Only
the MCP stdio reader serves `(withheld)` in its place, on a host with no
provider.
- The note is never evaluated: filter, sort, group and search are
refused, and it is refused as an analytics member.
- Copies withhold the quote, at write time and through the migration.

The history row itself is not rewritten: the history table stays the
lineage. This member is outside the ruling's literal enumeration, so it
is flagged for the contract review.

**Not exits (unchanged):** the HTTP cache validator (measured: it never
carries the stored hash), and realtime record events (out of scope by
the ruling; no public channel route in this repository).

**The engine** gains one additive read accessor beside
`setCryptoProvider`, for the registered provider's keyed digest. It is
read at each use, because a host registers the provider after the kernel
starts. It is narrower than the provider itself: no consumer is handed
`decrypt`.

## Measured on a real boot, before and after

Composition: showcase + automation + SQLite file database + audit plugin
+ the three connector plugins. Administrator and member API keys were
minted through the key door (201 / 201). The verify harness registers
the local crypto provider, as `os serve` does. Before is base
`ecb6ca0258`; after is this branch.

| Door, administrator | Before | After |
|:--|:--|:--|
| save, publish, rollback receipts | 200, token equals the stored head |
200, token is keyed and is not the stored head |
| history read | 200, every event hash and parent hash a stored hash |
200, all keyed, none stored |
| save and reset doors, raw stored hash sent back | 200, accepted | 409
`METADATA_CONFLICT` |
| save door, served token sent back | 200 | 200 |
| conflict refusal | 409, body carries the current stored hash | 409, no
stored hash |
| data door list and get, both tables | 200, stored values; on a
credential-bearing row, the served hash plus the projected body confirm
a right guess and reject a wrong one | 200, keyed; the guess no longer
confirms; stable across reads; a credential-only change still moves it |
| data door filter, sort, group on the hash columns | filter: right
guess 1 row, wrong guess 0 rows; group serves stored values | 400
`INVALID_FIELD` on each |
| data door search over the hash or body column | a right hash prefix
and a right credential prefix each match their row | no match; explicit
search fields naming one: 400 `INVALID_FIELD` |
| decision-audit note (`/meta` audit door, data door) | carries stored
hashes | none |
| ledger and activity copies written after the change | carry the stored
hashes | none (0 rows) |
| analytics grouped by a hash column | 200, serves stored values | 400
`INVALID_FIELD` |
| MCP stdio reader: query, get, record resource (both tables) | stored
values | keyed |
| MCP stdio reader: group, filter, sort on a hash column | run |
refused, `INVALID_FIELD` |
| history change note (member 14), stock row | — | served keyed by the
history read and the data door; filter and search refused |

Member, before and after alike: data door 403 `PERMISSION_DENIED`,
history door 403, ledger 403, analytics 403 `PERMISSION_DENIED`, and MCP
`PERMISSION_DENIED` on every member.

**Copies at rest**, measured through the CLI door on a database the base
code wrote:

| Step | Ledger copies with a hash | Activity copies with a hash |
Decision notes with a hash |
|:--|:--|:--|:--|
| before | 25 of 38 | 25 of 38 | 1 |
| dry run (exit 0) | unchanged; it reports 53 rows to rewrite |
unchanged | unchanged |
| `--apply --yes` (exit 0) | 0 of 39 | 0 of 39 | 0 |
| second dry run (exit 0) | 0 to rewrite | 0 to rewrite | 0 to rewrite |

The 39th row is the ledger copy of the migration's own rewrite of the
note, and it carries no hash. The history lineage keeps its 9 stored
hashes. On a stock database before the migration runs, the served copies
still carry the hash. That is the ruled path: operators run the
migration once after upgrading.

## Tests

Red first: the new pins were committed on the unfixed tree and run
there.

- metadata-protocol: 25 failed, 5 passed
- mcp: 11 failed, 3 passed
- plugin-audit: 14 failed, 88 passed
- service-analytics: 6 failed, 7 passed

Every red is a door serving or accepting the stored value. The controls
stayed green. The member-14 pins and the decision-note copy pin were
written after the fix, and their red is shown by ablation legs L06, L10,
L15 and L17.

Green, at the fix:

| Package | Result |
|:--|:--|
| metadata-protocol | full suite 3013 passed before the merge, then
re-run on the touched files after it |
| objectql | full suite 7358 passed; one conformance pin now registers a
crypto provider |
| rest | 4982 passed |
| runtime | 5081 passed |
| mcp | 380 passed |
| plugin-audit | 598 passed |
| service-analytics | 3793 passed |
| cli | unit project 3489 passed; the migrate preview integration file 6
passed, 1 skipped (the live PG cell) |
| dogfood | 17 affected files passed, among them the flow,
metadata-route, package-authoring, audit-log, activity, MCP and
permission-projection files |

`typecheck` exited 0 for metadata-protocol, objectql, mcp, plugin-audit,
service-analytics, rest and cli.

**Superseded pins updated:**

- Two decision-note pins used to assert that the note carries the
caller's token. They now assert the note withholds it.
- The batch-publish conformance pin asserts a non-empty token with no
provider registered. The first cut changed its composition. It is back
to its base bytes and passes as written.
- The absent-database audit pin that objectstack-ai#21432 added (a dry run of the
audit-metadata-bodies migration on a database that does not exist)
counted two tables unread. The audit now also reads the decision-audit
trail, so the pin counts every audited table: three, each named, none
scanned, exit 1. A control that removes the decision-audit table from
the run turns it red.

**Ablations.** The fix was committed first. Each of 17 legs went through
`scripts/ablation-replace.mjs`: the anchor hit once, the blob changed,
the targeted pin went red, and the restore showed blob == HEAD with an
empty `git diff HEAD`.

| Leg | Mutation | Red |
|:--|:--|:--|
| L01 | receipt served raw | 8 of 11 |
| L02 | raw token accepted inbound | 2 of 11 |
| L03 | history served raw | 3 of 11 |
| L04 | conflict carries the stored hash | 2 of 11 |
| L05 | note carries the token | 1 of 11 |
| L06 | publish door states no message | 1 of 11 |
| L07 | data-door columns served raw | 5 of 27 |
| L08 | data-door evaluate shapes unrefused | 12 of 27 |
| L09 | search not narrowed | 5 of 27 |
| L10 | quoted hash in a note served raw | 2 of 38 |
| L11 | MCP columns served raw | 3 of 16 |
| L12 | MCP evaluate shapes unrefused | 8 of 16 |
| L13 | analytics unrefused | 7 of 14 |
| L14 | writer copies the hash | 4 of 93 |
| L15 | writer copies a decision note's hash | 1 of 93 |
| L16 | migration keeps the columns | 7 of 12 |
| L17 | migration keeps a note's hashes | 5 of 12 |

**Patch round (CI falsified option A).** The fix lands at `7660d811a7`.
Validation and ablation results are in the os-dev-report for this round.
The SDK and CLI reset-door pins pass unedited. Restoring the empty
token, with dist rebuilt, turns them red again: 3 of 20 and 6 of 20, the
exact CI failures.

**Gates.** At `1ad5a0099e`:

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 84 commands. All 84 ran, every exit code recorded,
all 0.
- `--ran` reconciles 84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN.
- `check:error-code-casing` and `check:nul-bytes` exited 0.
- `pnpm lint` (the whole repository) exited 0.

Gate hygiene this needed:

- the new pinned engine doubles recorded through
`check-engine-double-contract --write`;
- one test double now holds the caller's bound;
- one where-matcher now refuses the combinators it does not implement;
- the migration reads the decision-audit code through an operator-form
predicate, because it is a read and not a stamp;
- the persisted audit vocabulary is marked in the pins.

## Acceptance notes

- **No crypto provider registered (option A falsified by CI,
replaced).** The first cut served an empty version token on a host with
no crypto provider. CI falsified that: two real reset-door pins, one in
the SDK and one in the CLI, showed that every save then handed out the
same empty token. A client that sends no pin for an empty token turned a
pinned reset into an unpinned one, so the optimistic lock failed open.
Replaced in this PR: while no provider is registered, the doors key
under a process-scoped ephemeral key (32 random bytes drawn on first
use, never written, logged or served). A token is always served, differs
when the content differs, and is never the stored hash. An empty or
withheld token sent back is refused with `409 METADATA_CONFLICT`, never
read as "no pin". A token held across a restart, or across a provider's
first registration, is refused once with the same 409. No stored value
carries a served token, so nothing persisted dies with the key. The MCP
stdio reader has no version-token door; it still omits the hash columns
on a host with no provider.
- **Where the hash-column list lives.** The family's natural home is
beside the body column's primitives in the spec kernel module, which is
outside this claim. metadata-protocol, mcp, plugin-audit and
service-analytics each name the same columns. The family enumeration pin
holds metadata-protocol's list equal to the columns the two object
definitions declare, and each other package's copy is pinned by its own
behaviour tests.
- **Stale spec descriptions.** The spec's descriptions of the save,
publish and batch-publish tokens still say the token is "currently
emitted as" an unkeyed hash. The format is declared outside the
contract, so this is prose drift for the spec seat.
- **metadata-core's base conflict text** still prints both stored
values. No door serves it: every door converts the conflict, and the
revert door withholds undeclared failures. So it is untouched.
- **Serial constraint.** objectstack-ai#21377 landed while this branch was in flight,
and origin/main was merged in (`41a3c8df15`). It adds no hash exit.
origin/main was merged again (`8ca49662e8`, which carries objectstack-ai#21432), and
that PR's absent-database audit pin was stacked with this one (see
Superseded pins).

Changeset: `minor`, with a BREAKING banner and one ADR-0087 disposition
(`not-required (no-migration-prescription)`). It states the three
consequences: a held token gets one 409; filter, sort and group on the
hash columns and the change note answer 400; operators run the extended
migration once, dry run first.

An independent contract review is owed before landing, per the ruling.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

2 participants