Repository navigation
feat(automation): a flow's credentials live in a write-only channel on the secret seam, not in its stored definition (#20790) - #21377
Conversation
…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>
…nnel 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>
…ow-hook-secret-seam
…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>
…ow-hook-secret-seam
…f a credential-holding source Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 6 package(s): 65 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 144 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 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
|
…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>
…ow-hook-secret-seam
…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>
…ow-hook-secret-seam
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37006699795 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Queue failure triaged: not this PR's; one re-queue ·
Generated by Claude Code · https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ |
…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>
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:enginepackages/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:clipackages/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).packages/qa/dogfood/(one pin, one dev dependency, one source alias) andpnpm-lock.yaml.--write.What changed
1. A write-only channel on the existing secret seam (
service-automation).sys_flow_credentialholds one row per credential position of a flow, per lifecycle state (draft or active).FlowCredentialChannelhandles five operations:2. The save door stores the body the channel returns (
metadata-protocol).registerCredentialChannel(type, channel)registers a channel.saveMetaItemruns it after the carry-forward and before the put, so the stored row, every new history row and the content hash carry no credential.restoreVersiontakesderiveRestoredBody. 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).SERVICE_UNAVAILABLE. It is never verified against nothing, and nothing is enqueued.4. C1: the clone door refuses a credential-holding source (
runtime).RESOURCE_CONFLICT, names the classes, and gives Q2 A's prescription: author the copy as a new flow with its own secret.5. A one-time move with a receipt (
service-automation).sys_migrationasflow-credential-channel, with counts and names only.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.
minorfor 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):
The end-to-end pin is
packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts(8/8).Every negative pin was ablated:
a38db79ecthroughscripts/ablation-replace.mjs. Each anchor hit, each pin turned red, and after each restore the tree read clean againstHEAD. Red counts ranged from 1 to 6 per ablation, across the channel, trigger, http-node, migration, clone and protocol pins.457f43476: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.--absentpassed, 8/8 green, and the diff againstHEADwas 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.runtimelocalproject: 3 shards, 304 files; 4335 passed, 11 skipped.speclocalproject: 2 shards, 598 files; 17512 passed, 1 todo.service-automation,metadata-protocol,trigger-api,runtime,dogfood) andspectsc: all exit 0.Gates:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 129 commands at80b4647b2, and all 129 exited 0. The--ranreconciliation answered:129 derived, 129 run, 0 NOT-MEASURED, 0 UNRUN.Declared to CI: the full dogfood suite, the runtime
repoproject, 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
84d3d297afixes it in the http node's shape, so both doors read one rule:Pins:
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--absentpreflight passed, and both pins went green again:At
417ba1fa6, after mergingorigin/main:The size is now 3646 changed lines (+3557 / −89), of which 1,678 are added test lines.
Acceptance notes
mainand 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.packages/runtime/src/domains/automation.tsis outside revision 5942559287's list and inside claim 5935167060. The clone handler there is where the C1 refusal is consulted.origin/mainmoved at least 10 commits after the gate derivation at80b4647b2. One derivation input changed (a release script, outside this diff), and a test merge against currentmainis clean.Generated by Claude Code