Skip to content

fix(mcp)!: the MCP stdio engine-only reader joins the stored-metadata-body family (exit one) - #21228

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21207-stored-body-two-exits
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21207-stored-body-two-exits

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #21207
Clause-②: no (narrowing)

This PR delivers exit one of #21207 only. Exit two (the served content hash) stopped at the claim's stop clause before any code was written; the reasons are below, and #21207 remains open for it.

What this does (exit one)

The MCP stdio transport reads stored metadata rows through the engine only: the stdio bridge's query, get and aggregate verbs, and the record resource. The engine returns a stored metadata body as stored, so an administrator's key was served credential material in cleartext from the stored-row table and the version-history table. Both read paths now join the stored-metadata-body family:

  • Serve the projection. Rows of the two stored-metadata tables are served through the family's one projection (redactStoredMetadataRow in @objectstack/spec/kernel), the same projection the generic data door and every metadata read use. Stored credential material is withheld, a body that cannot be judged is omitted (fail-closed), and a credential-free body is served byte-identical. No second projection: this package does not depend on @objectstack/metadata-protocol, and the projection lives in spec.
  • Refuse the evaluate shapes. A group, filter, sort or aggregate member on the stored body column is refused with INVALID_FIELD / 400 before the engine runs, the data door's code and envelope (the analytics door precedent: a door-local refusal built on the spec primitives).
  • The family pin. stored-metadata-body-family.pin.test.ts gains two rows for this door (seam and refusal), owned by @objectstack/mcp. The new per-package pin classifies every stdio bridge member and enumerates every engine read call site in packages/mcp/src on the syntax tree, so a future reader on this transport fails its pin instead of joining silently.

Files: packages/mcp/src/stdio-data-bridge.ts, packages/mcp/src/plugin.ts (record resource), the new packages/mcp/src/stdio-data-bridge.stored-metadata-body.test.ts, the family pin, and .changeset/21207-mcp-stdio-stored-metadata-body.md (minor, breaking narrowing under the launch-window convention, ADR-0087 not-required (no-migration-prescription)).

Before / after (live boot, administrator vs member, classes and statuses only)

Measured on a real boot (showcase, security, automation, SQLite), with real API keys resolved the way the stdio door resolves them. Before = origin/main at 097ef80270; after = this branch with the rebuilt @objectstack/mcp.

Door on the MCP stdio transport Body class Administrator before Administrator after Member before Member after
Record resource (resources/read), stored-row table flow body with credential served, credential in cleartext served as projection, no credential refused (engine PERMISSION_DENIED), no row same
Record resource and bridge get / query, both tables datasource body with credential (historical stock) served, credential in cleartext served as projection, no credential refused (PERMISSION_DENIED) same
Bridge get / query, both tables flow body with credential served, credential in cleartext served as projection, no credential refused (PERMISSION_DENIED) same
All of the above credential-free body (control) served served byte-identical refused same
Bridge query: filter on the body column flow, datasource answered a guess about the credential (match vs no match) refused INVALID_FIELD / 400, engine not asked refused PERMISSION_DENIED refused INVALID_FIELD / 400
Bridge query: sort; bridge aggregate: group, filter flow, datasource served, body in the answer refused INVALID_FIELD / 400 refused PERMISSION_DENIED refused INVALID_FIELD / 400
Bridge aggregate: member over the body flow, datasource refused by the engine (INVALID_FIELD / 400) refused here first (INVALID_FIELD / 400) refused refused
Tools query_records / get_record any refused by the tool layer's system-object guard unchanged same same

Datasource bodies: the save door refuses a credential in a datasource body (measured 422), so cleartext at rest is historical stock written before that refusal; it was measured on such rows. It is affected exactly as the flow body was, by construction and now by measurement.

Exit two: stopped at the claim's stop clause (not built)

Measured live, unchanged by this PR: the stored content hash served on this door and on the generic data door is an unkeyed sha256 over the whole stored body, credential included, and it equals the save receipt's version.

The claim asked to list every consumer that compares these hashes and where the server-held key comes from, and to stop if any consumer compares across deployments or if the key needs a new secret. The key source answers that stop clause:

  • No existing keyed primitive reaches the repository. The crypto provider contract offers encrypt, decrypt, rotate and an audit digest; the contract allows that digest to be plain SHA-256, and the local provider's is unkeyed. The provider is attached to the engine after the runtime starts and is not exposed to the metadata repository, while metadata saves already run during boot.
  • So a keyed hash needs either a new secret, or a new keyed method on the crypto provider contract (a packages/spec contract widening that every provider must implement) together with a boot-ordering change.
  • Beyond the key, the change reaches outside the declared surface: the repository contract's canonical-hashing invariant and the shared contract suite (packages/metadata-core), the spec's description of the history checksum as SHA-256, the stored-row checksum column's length bound, and a second producer of the same columns in packages/metadata.
  • Cross-deployment comparison: none was found in this repository; the cloud repository was not measured.

The full consumer list, the coexistence analysis for existing rows and parent links, and the options are in the os-dev report on #21207.

Tests and verification (head ae754f9e3a)

  • @objectstack/mcp: pnpm test 33 files / 366 tests passed; pnpm typecheck exit 0 (the new test is in the test-layer program, no debt added).
  • @objectstack/metadata-protocol: vitest run 200 files passed, 3 skipped / 2973 tests passed, 19 skipped; pnpm typecheck exit 0.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands derived 61 commands at ae754f9e3a; all 61 were run, each exit 0, and --ran reconciled 61 derived / 61 run / 0 not measured.
  • pnpm lint (repo-wide, eslint . --no-inline-config): exit 0 at ae754f9e3a.
  • Ablations (fix committed first, mutation through scripts/ablation-replace.mjs, restore proven against HEAD):
    • A1, projection removed: 5 administrator no-credential pins red (query, get, unjudgeable body, body-only projection, record resource); 17 green, controls included.
    • A2, evaluate refusal removed: all 9 refusal pins red; 13 green, the scalar-column control included.
    • A3, an extra engine read site added behind a cast: the call-site enumeration red; 21 green. History, stated rather than hidden: the first A3 attempt was a no-op (the replacement kept the anchor, so the tool refused it and nothing ran); re-run with a different anchor, it stayed green against the then name-based scan, which is why that scan was replaced by the syntax-tree one. The reading above is against the final scan.
    • A4, an unclassified bridge member added: the member classification red; 21 green. Its first attempt was likewise a refused no-op, re-run with a different anchor.

Acceptance notes

  • The record resource is the reachable public door for exit one on today's stdio composition. The query_records / get_record / aggregate_records tools refuse the stored-metadata tables through the tool layer's system-object guard (default on). The bridge itself is now safe regardless of that guard.
  • Pin half not met: exit one still serves the stored hash column as stored, so "no unkeyed hash" on this door waits on the exit-two decision. This PR does not narrow the column on one door alone, since the ruling chose a keyed hash over a narrowed one.
  • This door judges a dotted path into the body column by its head segment. The data door's refusal matches the exact column name; its behaviour on a dotted path was read from source, not measured.
  • For a member, the evaluate shapes now answer INVALID_FIELD / 400 before the engine's PERMISSION_DENIED. This is the data door's order; no row is served either way.

Generated by Claude Code

claude added 5 commits October 1, 2026 18:08
…etadata-body family

The MCP stdio bridge (query / get / aggregate) and the ADR-0101 record resource
read through the engine only, so a stored metadata body reached an
administrator's key as stored. Both now serve the body through the family's one
projection (`redactStoredMetadataRow`, `@objectstack/spec/kernel`) and refuse
the shapes that would evaluate it (group, filter, sort, aggregate member) with
the data door's INVALID_FIELD / 400 envelope. The family enumeration pin gains
the two MCP rows; the per-package pin classifies every bridge member and every
engine read call site of the package.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…s name

An ablation showed a cast-wrapped receiver escaped the name-based scan. The pin
now keys each site by its verb and first argument, so any receiver is caught
and a callback-taking array method is not.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
… private comment stripper

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

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/mcp, touching 11 documentable anchor(s).

19 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 7c5a311a5829ae3b550a3eeb9bb984f06be8b865.

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

What this run could not see
  • 3 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 — 12 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 7c5a311a5829ae3b550a3eeb9bb984f06be8b865 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7c5a311a5829ae3b550a3eeb9bb984f06be8b865

⚠️ 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 7c5a311a5829ae3b550a3eeb9bb984f06be8b865 → 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 1, 2026 19:14
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 19:14
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 3ddd3d0 Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21207-stored-body-two-exits branch October 1, 2026 19:44
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…commits and ADR that decided them (objectstack-ai#21233)

Part of objectstack-ai#20595
Clause-②: no

## What changed

Stage 1 of the `domain:engine` lane of the dead-citation sweep:
`packages/metadata-protocol/**`, comment and docblock prose only, per
the claim (`5938223120`). The next stages (`objectql`, `driver-sql`,
`driver-memory`, then the rest) are separate claims, so objectstack-ai#20595 remains
open.

Every comment or docblock site in the package that cited a tracker
number answering 404 is rewritten in ruling C+D's form C (record
`5749154545` on objectstack-ai#19123): the ADR or ruling record when one exists,
otherwise the commit in this repository's history that made the decision
the sentence describes. That is **293 sites on 279 lines in 54 files,
covering 56 numbers**:

- **163 census sites** (155 lines, 8 files under `src/`): the whole
`allocated-but-absent` population of the gate's own census in this
package at the base;
- **1 site in `tsup.config.ts`** (`:15`, `objectstack-ai#11235`): same number, outside
the census glob but inside the claimed file surface;
- **129 test-comment sites** (123 lines, 45 test files), which the
census defers, found by the supplementary reading below. Each cites a
number the census itself reads as dead (41 of the 43 numbers are among
the census set; `objectstack-ai#10485` and `objectstack-ai#8600` are dead elsewhere in the
repository).

**Anchors: 55 numbers by commit sha, 1 by ADR (`objectstack-ai#13185`, ADR-0005's
design-principle-3 correction), 0 by words alone.** `objectstack-ai#11674` is split
across two commits, one per half of what it named (see the table). Two
`objectstack-ai#12176` sites (`protocol.item-name-grammar.test.ts:6`, `:12`) drop the
number without a new citation, because line `:4` of the same docblock
now cites the commit (`311433f6b`) that both sentences describe.

Only comments changed. Every file keeps its line count (280 lines out,
280 in, plus the changeset), so no line citation into any of them moves.
No code token moves (the guard below). **No citation number is added**:
every number on an added line already stood on its line, and 20 of those
23 are live by the census's own judgement. Of the 3 it never reads (they
stand only in test files), `objectstack-ai#11099` and `objectstack-ai#8390` answer as pull requests
and `objectstack-ai#14767` answers 404 (see Sites left).

**A `patch` changeset**: 52 of the rewritten non-test lines are in the
published `dist` (the `.d.ts` keeps JSDoc on exported members, and
esbuild keeps a few comments in the JS), and `dist` is not
byte-identical with the base text (see Changeset).

## Census: `metadata-protocol`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged (it carries objectstack-ai#20989's wider extractor, merge `a5bce40888`, an
ancestor of the base). The count is its `allocated-but-absent` findings
under `packages/metadata-protocol/`.

| reading | tree | board | whole-repo `allocated-but-absent` | sites |
lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `e47355be5`, run ended 18:52:32Z | enumerated, 191
pages, frontier objectstack-ai#21227, 19,048 records | 755 | **163** | 155 | 8 | 54 |
| after | `06d41e512`, run 19:21:20Z to 19:25:08Z | enumerated, 191
pages, frontier objectstack-ai#21228, 19,049 records (newest number objectstack-ai#21228 read just
before and just after the run) | 592 | **0** | 0 | 0 | 0 |

The whole-repo drop is 163, and the two finding sets differ by exactly
the 163 rows of this package, removed; none was added. `resolves`
(34,516), `resolves-as-pull-request` (2,092) and `cross-repo-unjudged`
(1,139) did not move. The card's 162 was taken at `f11b5f20a2` with the
older extractor; the base here reads 163, which includes the
slash-joined `partial-index-probe.ts:395` `objectstack-ai#16657` that the post-landing
census (`5923084795`) named. The only commit after `06d41e512` adds the
changeset file, which is outside the census surface.

**Supplementary instrument, the whole package.** The census reads
neither test files nor strings nor files outside `src`. A second reading
runs the gate's own exported `extractCitations` (whole-file and
comment-prose projections) and `namesThisRepository` over every tracked
file in the package (235 `.ts`, 2 `.md`, 2 `.json`). A number is dead
when the before census reported it `allocated-but-absent`, and live when
the census's own scope extraction judged it and did not report it. The
82 numbers neither covers (they stand only in test files, strings or the
changelog) were each read on their own (issues endpoint, which also
answers pull requests): 27 issues, 38 pull requests, **17 answer 404**.
Controls: `objectstack-ai#10888`, `objectstack-ai#11674` and `objectstack-ai#16657` (the card's and the census's
named sites) answer 404, `objectstack-ai#5286` and `objectstack-ai#12624` answer 200.

| reading | citations | dead | src comment | test comment | test string
| changelog |
|---|---|---|---|---|---|---|
| before, `e47355be5` | 6,383 | **434** | 164 | 151 | 63 | 56 |
| after, head | 6,090 | **141** | 0 | 22 | 63 | 56 |

`src comment` here includes `tsup.config.ts`. Its before value is the
census's 163 plus that one site, which is the control on the second
instrument. The drop of 293 citations is exactly the rewritten sites. A
third, raw reading (every `#` followed by 2 to 6 digits, whatever
surrounds it) counts 6,479 before and 6,186 after: the same drop of 293.
The 22 test-comment sites left all carry numbers outside the census's
surface (see Sites left).

## Per-number table

`src` counts census sites (plus `tsup.config.ts` for `objectstack-ai#11235`), `test`
counts test-comment sites. Every sha below matches exactly one commit
(`git rev-parse --disambiguate`, count 1) and is an ancestor of the base
(`git merge-base --is-ancestor`, exit 0 for all 56; the clone was
unshallowed first, `--is-shallow-repository` false, 15,415 commits at
the base). Each one's message or diff names the number it replaces (diff
counts are the added lines naming it), and for every sentence that
credits a ruling, a measurement or a note to the number, the commit's
own message carries that ruling, measurement or note: `ee58392e1` (the
2026-08-08 three-part ruling), `c74aefe63` (ruling 2026-08-22, option
A), `65846bc46` (ruling A, 2026-09-03), `75e66fc8e` (Option B, diff raw
then redact), `96326040f` (the idempotence proof the direction-A ruling
was conditional on), `8744de9e9` (the second-rung ablation), `82cb6e849`
(the two faces left open), `376c70f98` (the measured `shims: true`
consequence). The one exception is `2a29caa53` (`objectstack-ai#9741`): its message
records the decision itself (`environmentId` recorded as
transport-level) but not the 2026-08-18 ruling, so that site keeps its
own date and now reads 「recorded 2026-08-18, landed as commit
2a29caa」. 37 of the 56 numbers were already re-anchored by other
lanes' stages, and for every one of them this stage uses a commit those
stages used (none differs; `objectstack-ai#11674` adds `9a884c6e4` beside their
`1cba33f16`, because 25 of its 32 sites here describe the write-back
half, which `git blame` puts in `9a884c6e4`). The other 19 had no prior
anchor and were measured here.

| number | src | test | anchor | kind | what it decided |
|---|---|---|---|---|---|
| `objectstack-ai#6037` | 1 | 1 | `18189983d` | commit | validate-only data operation
— DataProtocol.validateData |
| `objectstack-ai#6307` | 1 | 0 | `293476148` | commit | refuse a repeated `?version=`
on `GET`/`DELETE /packages/:id` instead of handing the array to
PackageService |
| `objectstack-ai#6478` | 1 | 4 | `474f131cf` | commit | rolls `flow`'s
`allowOrgOverride` back to `false` per ADR-0005's original call, the
write path refusing loudly |
| `objectstack-ai#6483` | 8 | 10 | `ee58392e1` | commit | enforces the ADR-0005
whitelist: nine unratified `allowOrgOverride: true` flags rolled back to
`false`; its message records the 2026-08-08 three-part maintainer ruling
it executes |
| `objectstack-ai#6608` | 4 | 2 | `ee58392e1` | commit | the same commit: `objectstack-ai#6608` was
the pull request whose squash it is |
| `objectstack-ai#8600` | 0 | 1 | `018d22cc3` | commit | require authored OWD at the
runtime object door; retire ADR-0094 R2 external-wider arm; declare
object in runtimeTypes |
| `objectstack-ai#8648` | 2 | 0 | `e5eeb499c` | commit | pin the SEARCH-axis remedy
agreement, and correct the three comments that claimed word-identity |
| `objectstack-ai#8671` | 1 | 2 | `75e66fc8e` | commit | stop the meta diff endpoint
serving credential values |
| `objectstack-ai#8818` | 1 | 1 | `fd6bdf89f` | commit | saveMetaItem's missing-item
refusal declares 400 INVALID_REQUEST instead of answering 500 |
| `objectstack-ai#9740` | 1 | 0 | `11b779e0f` | commit | declare
MetadataProtocol.getMetaItemLayered; drop the dead 'overlay' lockSource
arm |
| `objectstack-ai#9741` | 1 | 0 | `2a29caa53` | commit | declare previewDrafts/state
on meta-read requests; record environmentId as transport-level; retire
REST door casts |
| `objectstack-ai#9798` | 1 | 0 | `c7655d472` | commit | restore the objectstack-ai#4630 unscoped
multi-delete refusal on sys_comment through the wired engine |
| `objectstack-ai#9817` | 1 | 1 | `855591fe7` | commit | discriminate a failed
sys_organization probe from a genuinely empty one |
| `objectstack-ai#9934` | 13 | 2 | `79c46da90` | commit | producer-side user-facing
marking for hook refusal messages — userMessage channel |
| `objectstack-ai#9967` | 2 | 1 | `8f266f1cd` | commit | serve a sandboxed body's
declared HTTP status on /api/v1/data |
| `objectstack-ai#10063` | 5 | 1 | `9e04c3e35` | commit | let the publish door state
the package it is promoting |
| `objectstack-ai#10159` | 1 | 0 | `1ec36b730` | commit | refuse a settings write
issued before the engine is bound |
| `objectstack-ai#10340` | 3 | 4 | `26f3588fb` | commit | decide /meta org scope on
the folded type, not the raw URL spelling |
| `objectstack-ai#10350` | 5 | 3 | `490879ad0` | commit | declare `packageId` on
`publishMetaItem`'s request type, and correct three comments that say
the per-item door names no package |
| `objectstack-ai#10382` | 1 | 4 | `ee09d2119` | commit | derive each live-MySQL
suite's database from its own file, and enforce it repo-wide |
| `objectstack-ai#10485` | 0 | 10 | `35ad101bc` | commit | retire the `themes` carrier
key and ThemeSchema — `app.branding` is the one colour surface |
| `objectstack-ai#10788` | 1 | 1 | `3a7ec2d3b` | commit | a raw-SQL seam that cannot
answer is absent, not empty |
| `objectstack-ai#10789` | 6 | 1 | `38bc74ed1` | commit | a seam that cannot answer is
absent, not empty |
| `objectstack-ai#10842` | 1 | 3 | `f334d662e` | commit | watch(_, since) replays from
sys_metadata_history, and what a bare watch() owes is written down |
| `objectstack-ai#10886` | 3 | 10 | `809e61221` | commit | inventory the
DESTRUCTIVE_CHANGE 409's faces and pin the sole carrier |
| `objectstack-ai#10888` | 5 | 4 | `d806081dd` | commit | render the spec-validation
422 findings clause per write face |
| `objectstack-ai#10895` | 1 | 1 | `a79bd3561` | commit | Publish refusals: declare
failed[].issues + seedApplied.issues, then trim error to a headline |
| `objectstack-ai#11003` | 5 | 1 | `c74aefe63` | commit | thread packageId into both
resolveDraftOrgScopeForPublish probes |
| `objectstack-ai#11014` | 1 | 2 | `2d8b92ff1` | commit | the destructive gate's
reachable type set is `object` alone |
| `objectstack-ai#11015` | 6 | 9 | `82cb6e849` | commit | make the destructive-change
remedy clause face-aware — stop prescribing `?force=true` on the
duplicate door |
| `objectstack-ai#11021` | 3 | 1 | `7d81c889f` | commit | close() terminates watch
iterators instead of emitting a drain event |
| `objectstack-ai#11235` | 6 | 1 | `376c70f98` | commit | derive discovery `version`
instead of the hardcoded `'1.0'` literal |
| `objectstack-ai#11350` | 2 | 0 | `ece4dad31` | commit | re-export the three types
the root entry's own inferred types mention |
| `objectstack-ai#11674` | 20 | 12 | `9a884c6e4` + `1cba33f16` | commit | seed pass 2
writes back by the internal id captured at insert time, healing keyless
datasets / warn at load time when a seed defers a required column, and
document the ordering constraint at the four pointer-pair sites |
| `objectstack-ai#12144` | 1 | 0 | `3a04b0125` | commit | pin the shared identifier
schemas to the storage columns that bound them |
| `objectstack-ai#12176` | 2 | 3 | `311433f6b` | commit | Declare the metadata
item-name grammar in spec and refuse it loudly at the publish door |
| `objectstack-ai#12194` | 6 | 5 | `311433f6b` | commit | Declare the metadata
item-name grammar in spec and refuse it loudly at the publish door |
| `objectstack-ai#12195` | 1 | 0 | `7986d973f` | commit | Retire compound-name
metadata addressing — un-mount the three `:section` arities and unify
SDK URL spelling |
| `objectstack-ai#13185` | 1 | 1 | ADR-0005, design principle 3, its Correction note |
ADR | the field-level patch model retired and deleted whole under
ADR-0049 (executed as `9e0ba21a1`) |
| `objectstack-ai#13186` | 1 | 1 | `9e0ba21a1` | commit | Retire the paper
metadata-customization protocol with its full coupling set |
| `objectstack-ai#13259` | 1 | 1 | `2a75270b1` | commit | honour `hidden` on
getUiView's list priority pass |
| `objectstack-ai#13324` | 4 | 2 | `4cda78c9b` | commit | require a missing-table
error to name the table that was read |
| `objectstack-ai#14390` | 1 | 0 | `9d7f7259f` | commit | `update` answers a driver
unique violation with the `DUPLICATE_RECORD` envelope, on every driver |
| `objectstack-ai#14403` | 1 | 0 | `93d2d679b` | commit | pin the batch-row sink's
disclose/withhold log coherence |
| `objectstack-ai#14409` | 2 | 3 | `3ecb7dc1a` | commit | measure what each dialect
materialises for a datetime JS cannot hold |
| `objectstack-ai#14541` | 1 | 0 | `6d178a408` | commit | consult the bespoke
structured arms before the declared-status passthrough, so both error
doors answer one refusal with one body |
| `objectstack-ai#14683` | 6 | 5 | `96326040f` | commit | apply the allowOrgOverride
read gate inside getMetaItems, so multi-type sweeps are scoped per type
|
| `objectstack-ai#14723` | 3 | 1 | `65846bc46` | commit | a batch/import ROW reports a
unique-constraint refusal as `UNIQUE_VIOLATION`, the route's one wire
spelling |
| `objectstack-ai#14770` | 3 | 3 | `d5cbb44f3` | commit | gate `getMetaItem`'s overlay
read on the metadata registry |
| `objectstack-ai#14907` | 1 | 2 | `e1d4f9e3f` | commit | `getMetaItemLayered` gates
the org read, bound after the canonical fold |
| `objectstack-ai#14938` | 2 | 1 | `c383352cb` | commit | listDrafts emits the
ISO-8601 string updatedAt declares |
| `objectstack-ai#15068` | 1 | 0 | `8744de9e9` | commit | collapse the published-seed
read to the single env-wide read its gate produces |
| `objectstack-ai#16488` | 5 | 1 | `460d4b807` | commit | render a composite
externalId in seed diagnostics instead of its NUL-joined key |
| `objectstack-ai#16657` | 3 | 1 | `5a95b0e93` | commit | read the dialect text out of
`cause` for operator-facing records |
| `objectstack-ai#17167` | 4 | 5 | `dc709b2cf` | commit | the organization probe
records the operator channel as is, empty included |
| `objectstack-ai#19306` | 1 | 1 | `f9e16d856` | commit | a packaged permission set's
DELETE stops reporting a deletion it did not perform |

`objectstack-ai#13185`: the ADR rung is not empty there. ADR-0005's design principle 3
carries a dated Correction that records the 2026-08-29 retirement of the
field-level patch model, so ruling C's first rung applies.
`protocol.ts:8618` already names that record on the same line (「recorded
as a correction inside principle 3 itself」), so there the number is
dropped beside `commit 9e0ba21`.
`get-meta-item-org-read-gate.test.ts:40` now names it (「ADR-0005
principle 3's correction」). For the other 55 numbers, `git grep` over
`docs/adr` and `scripts/adr-anchors` finds no ADR or anchor that records
the decision a site describes. ADR-0094 D5-R and ADR-0086 mention the
`objectstack-ai#6483` rollback, but only as a pointer to it; the narrative and the
ruling are in `ee58392e1`'s message. So ruling C's commit rung applies.

## Wordings to check

Most rewrites swap a tag in place (`(#N)` to `(commit SHA)`, `[#N]` to
`[commit SHA]`, `#N's X` to `commit SHA's X`), the form the landed
stages use. These are the ones that say more than the tag:

- `protocol.ts:5697`: 「(objectstack-ai#9798 declared-but-unenforced, …」 became
「(commit c7655d4 restored a declared-but-unenforced refusal, …」. The
number named an instance of the class, and that commit is the one that
restored it.
- `protocol.ts:8590`: 「the resurrection objectstack-ai#14683 is about」 became 「the
resurrection commit 9632604 closed」.
- `protocol.ts:16640`: 「measured on the objectstack-ai#12176 census before this
landed」 became 「measured before this landed (the census commit 311433f
records)」. The census results are written into that commit's test-file
header.
- `migrations/seed-tenancy-backfill.ts:964`: 「Measured; recorded
separately as objectstack-ai#10159.」 became 「Measured; recorded separately, and
refused since commit 1ec36b7.」 That commit refuses the settings write
that answered "resolved" while persisting nothing.
- `discovery-version.ts:26`: 「considered and declined at objectstack-ai#11235 triage」
became 「considered and declined when the derivation landed (commit
376c70f)」. The triage discussion is not recorded in-repo. The commit
is where the package-local resolver was chosen, and its message records
why: the dependency direction forbids importing runtime's.
- `seed-loader-pointer-pair.test.ts:871`: 「— objectstack-ai#11674's B half, ruled by
triage…」 became 「— commit 1cba33f, the B half, ruled by triage…」.
- `protocol-publish-drafts-package-scope.test.ts:400` is the one changed
line that carried no number. 「option A (recorded on the issue)」 became
「option A (recorded in that commit's message)」, because the issue it
pointed at was the number removed on `:399`, and `c74aefe63`'s message
does record the ruling.
- `sys-metadata-repository.contract.test.ts:187` quotes a deleted line,
「`declaredDivergences: { resumableWatch: 'objectstack-ai#10842' }`」. The quoted value
is elided to 「…」 rather than re-spelled, so the quote stays true.
- `protocol.item-name-grammar.test.ts:6` and `:12`: the number is
dropped and nothing is substituted, since `:4` cites `311433f6b`.
- No line was reflowed, so many are longer than their block's wrap
(`eslint.config.mjs` declares no line-length rule, and reflowing would
move neighbouring lines and every line citation into the file).

## Sites left

- **In `src` comments and `tsup.config.ts`: none.**
- **Test comments: 22 sites carry 13 numbers that answer 404 and that
the census never reads** (they stand only in test files). By the
dispatch's rule they are not this stage's population, so they are
counted and not edited: `objectstack-ai#6287`, `objectstack-ai#10058`, `objectstack-ai#10064` (2), `objectstack-ai#10420`,
`objectstack-ai#10978` (2), `objectstack-ai#11017`, `objectstack-ai#13214`, `objectstack-ai#13244`, `objectstack-ai#13258`, `objectstack-ai#14389`, `objectstack-ai#14431`
(5), `objectstack-ai#14767`, `objectstack-ai#17621` (4). `objectstack-ai#14767` stands on a line this PR rewrote
(`get-meta-item-org-read-gate.test.ts:10`): it is the pull-request
number of `96326040f`'s squash, kept beside the new anchor as it stood.
- **String literals: 63 test-string sites** (describe and `it` titles,
assertion arguments) carry dead numbers: 56 with census-dead numbers
(`objectstack-ai#12194` 6, `objectstack-ai#10789` 5, `objectstack-ai#10886` 5, `objectstack-ai#11014` 4, `objectstack-ai#11674` 4, `objectstack-ai#16488` 4,
and 20 more numbers once to three times) and 7 with `objectstack-ai#17621`. Non-test
source strings carry none. Strings are outside this stage's file
surface.
- **Outside `src`:** the release-owned `CHANGELOG.md` names dead numbers
on 56 sites (45 census-dead, 11 among the 404 reads); left. `README.md`,
`package.json` and `tsconfig.json` name no number; `vitest.config.ts`'s
two are live.

## Mechanical guard: no code token moves

The guard compares, base `e47355be5` against the working tree, over all
54 touched `.ts` files:

- **Reading 1**: the TypeScript parser's leaf nodes, from a
`forEachChild` walk. Comments are trivia there, and JSDoc is never
visited.
- **Reading 2**: the full token stream in parser context, from a
`getChildren` walk. Punctuation and keywords are included and JSDoc
nodes are skipped. String, template and numeric literals are compared in
full on both readings.

Results:

- Real run at the head: 213,265 base tokens, **0 files with a token
change** on either reading (exit 0).
- Comment control (「The derived」 to 「The DERIVED」 on `protocol.ts:13`):
0 files changed (exit 0).
- Positive control, a code identifier (`postureEnforcesWall` to
`postureEnforcesWallX` in `protocol.ts`'s import): DIFFER in both
readings (exit 1).
- Positive control, a string literal (`'dashboard'` to `'dashboardX'` in
`sys-metadata-repository.contract.test.ts`): DIFFER in both readings
(exit 1).
- Positive control, a numeric literal (`BULK_BATCH_SIZE = 200` to `201`
in `seed-loader.ts`): DIFFER in both readings (exit 1).

Every mutation went through `scripts/ablation-replace.mjs` (wrap mode)
under a shell trap that restores by absolute path from `HEAD`. Each
landed: anchor count 1 to 0, blob changed. Each restore was proven equal
to its `HEAD` blob (`5be50ab59075`, `99ef73ef7562`, `41b999ca3ecc`),
with `git diff HEAD` empty and a clean tree afterwards.

## Changeset: `patch` (`dist` measured)

`files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is
not private. The dependency closure was built first (`turbo run build
--filter='@objectstack/metadata-protocol^...'`, 12 tasks). Then the
package's own `build` (tsup plus `check-dts-emitted`) ran three times
under the shared verify lock:

- **Leg 1**, at the head: 24 `dist` files hashed. Of the 154 rewritten
non-test lines, 52 appear verbatim in `dist`: 36 from `protocol.ts`, 7
from `sys-metadata-repository.ts`, 5 from `seed-loader.ts` and 4 from
`migrations/seed-tenancy-backfill.ts`. Most are in `index.d.ts` /
`index.d.cts`; two from `seed-tenancy-backfill.ts` are in `index.js` /
`index.cjs`, where esbuild keeps a comment inside an expression.
- **Leg 2**, with the base text put back in the 8 non-test files (each
proven equal to its base blob): `index.d.ts`, `index.d.cts`, `index.js`
and `index.cjs` differ from leg 1, and so do the content-hashed chunk
names, including the seed-loader chunks.
- **Leg 3**, after the proven restore: all 24 files are byte-identical
to leg 1, so the build is deterministic and the difference is the
rewrite.

So the rewrite ships, and
`.changeset/20595-metadata-protocol-provenance-anchors.md` declares a
`patch` for `@objectstack/metadata-protocol`, comment text only, with
the claim's `Clause-②: no` line.

## Gates (head `3265b142f`)

- **Citation judging, as CI runs it:** `node
scripts/check-issue-citations.mjs` exits 0 (「every citation this change
adds resolves」, 19 citations judged across 8 files). `pnpm
check:issue-citations` exits 0 (self-test, 173 cases, 9 batteries).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0 (17,085 spec
strings clean; the sibling-package prose-id baseline holds, no growth).
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `3265b142f` (change set
derived from git: 55 paths against merge base `e47355be5`) derived 62
commands. All 62 ran, each with its exit code captured before any pipe,
and all 62 exit 0. `--ran` reports 62 derived, 62 run, 0 NOT-MEASURED (a
derived zero), 0 unrun, and exits 0. A full `turbo run build` over
`./packages/*` and `./packages/*/*` ran first under the shared verify
lock (71 of 71 tasks), so no gate hit an unbuilt workspace.
- **Roster families the derivation lists outside its commands** whose
roster sits in a directory this diff touches: `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver` and
`pnpm check:error-code-casing`. Each exits 0.
- **Tests and typecheck, under the verify lock, at `3265b142f`:**
- `pnpm --filter @objectstack/metadata-protocol test`: 200 test files
pass and 3 skip (203); 2,973 tests pass and 19 skip.
- `pnpm --filter @objectstack/metadata-protocol typecheck` exits 0, and
`tsc --noEmit --listFiles` puts all 203 tracked test files in the
program (233 package files).
- **Lint, as a proven narrowing:** eslint with inline config disabled,
over the 54 touched `.ts` files plus `dist/index.js` as the control,
gives 55 results, 0 errors and 1 warning: the control's ignore notice.
Its `--format json` output reports none of the 54 ignored.
`eslint.config.mjs` never enables type-aware linting (its lines 327-328
say so), so a comment edit cannot move the verdict on any untouched
file. The repo-wide `pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the 55 changed files for control bytes finds none.

## Acceptance notes

- **Base.** The branch is on `main` at `e47355be5`. `main` has since
moved five commits (to `62b90d74f`), and `dispatch-gates` flags that as
a stale tree. None of the five touches a file in this diff,
`scripts/check-issue-citations.mjs` or `scripts/pm/dispatch-gates.mjs`.
One edits `protocol.meta-types-degenerate-derivation.test.ts` in this
package, adding a citation beside a live one. The one derivation input
that moved, `scripts/doc-authoring-prose-id.baseline.json`, lost 63
lines, none of them naming this package. No merge was taken; the merge
queue rebuilds on the merged generation.
- **The before census was not bracketed by newest-number reads.** It
enumerated 191 pages at frontier objectstack-ai#21227; the newest number read at
19:21:20Z, before the after run, was objectstack-ai#21228.
- **Comment ids are outside the grammar.** `comment 5299845282` stands
twice in this package (`protocol.ts:22793`,
`protocol.diff-credential-redaction.test.ts:19`) and names a comment on
the deleted `objectstack-ai#8671`, so it no longer resolves either. Neither instrument
reads it, and `75e66fc8e`, now cited beside it, carries the ruling's
text in its message. Left as it is.
- **Wording only:** 「the card」 / 「this card」 stands on 377 comment lines
in 105 files under this package. It carries no number, neither
instrument sees it, and this diff removes no antecedent except the one
repaired at `protocol-publish-drafts-package-scope.test.ts:400`.
- **A first guard reading was void.** The guard's first version read the
token stream with a bare scanner, which has no parser context. It loses
its place at template literals and reported 27 files changed; its
parser-context reading reported 0 on that same run. That bare-scanner
reading was replaced by the `forEachChild` walk above, and every figure
in the guard section is from the replacement.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…n the secret seam, not in its stored definition (objectstack-ai#20790) (objectstack-ai#21377)

Fixes objectstack-ai#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 objectstack-ai#21228 change). objectstack-ai#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](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants