Skip to content

spec(ui): subtract the unenforced context key from ActionEngineFacade.find's query envelope - #19315

Merged
os-litant merged 5 commits into
mainfrom
claude/issue-19237-facade-find-context-declared-unenforced
Sep 20, 2026
Merged

os-litant merged 5 commits into
mainfrom
claude/issue-19237-facade-find-context-declared-unenforced

Conversation

@os-litant

@os-litant os-litant commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19237

Clause-②: no

⚠️ Notation: TypeScript angle brackets are written with PARENTHESES throughout this body — Omit(EngineQueryOptions, 'context') means the Omit utility type. The platform rewrites tag-shaped fragments in a body, and a fence does not protect them, so the real spelling lives in the diff.

The action facade's find accepted a caller-written context that type-checked and the runtime did not honour — ADR-0049's declared-but-unenforced shape on the one key that carries identity and tenant. This takes the remove arm, at the declaration layer only: the parameter becomes Omit(EngineQueryOptions, 'context'). No runtime behaviour changes.

The premise, measured FIRST — it HOLDS

The dispatch made the ruling conditional on a census: no call site writes a context on a facade query and relies on it to narrow identity or tenant. Measured before a line of fix was written.

Instrument (census3.mjs, three arms, run against the tree at 1739f71879f):

Arm What it matches Why it exists
A RECV.engine.VERB( where RECV is an action-ctx name the canonical handler spelling
B bare engine.VERB( in a file that destructures engine out of a ctx examples/app-todo writes it this way
C V.VERB( where V is assigned from buildActionEngineFacade(...) closes arm A/B's blind spot: a facade held in a local variable

Each call's second argument is extracted by balanced-paren scan, not a line regex, so a multi-line envelope is read whole.

Radius: 8292 tracked text files — the whole repository, not the importers of ActionEngineFacade. That denominator is deliberate, and it is the one PR #19223 warned about: the facade is reached through ActionHandlerContext.engine, so an importer count of the facade type is the wrong population.

Readings, exit codes captured before any pipe:

  • facade call sites 123 (armA 86, armB 10, armC 27); of these 47 are find
  • sites writing a context key: 11
  • find sites writing a context key: 1

That one is packages/runtime/src/action-engine-facade-find-envelope.test.ts:126 — the pin that asserts the key is NOT honoured, added by #19223. It is the instrument's firing control: arm C demonstrably sees a real facade find carrying a context.

The other 10 are not facade sites, and each was classified by reading the file rather than by name:

  • 7 in packages/objectql/src/internal-fields.test.ts — ctx there is Awaited(ReturnType(typeof buildEngine)), a real ObjectQL engine; sibling calls to findOne and aggregate are members ActionEngineFacade does not declare.
  • 3 in action-engine-facade-find-envelope.test.ts:209-211 — a different engine, built by the file's own makeRealEngine(); they pass a third argument, and the facade's insert takes two.

Dark control: the same instrument with a member and a builder that cannot exist (.engineZZZQ, buildActionEngineFacadeZZZQ) — exit 1, FACADE_SITES total=0 on all three arms.

Sibling radius: objectui at dda8f3815df — git grep for ActionEngineFacade, ActionHandlerContext and ctx.engine. exits 1 / 0 hits, with a firing control in the same tree (a token that certainly exists) exiting 0.

⇒ Zero live call sites. The p0 upgrade trigger does not fire. priority:p1 stands.

Mechanism: OVERRIDE, not drop — traced to a named line

At origin/main = 1739f71879f, read 2026-09-20T08:42Z:

packages/runtime/src/action-execution.ts:1620

const rows = await ql.find(object, { ...(query ?? {}), context } as any);

context is spread last, after the caller's envelope, so the facade's own elevated ExecutionContext (minted at :1560 by buildActionExecutionContext(ec)) replaces whatever the caller put under that key. The key reaches the engine; the caller's value does not. PR #19223's body claim holds on today's tree, and it is override rather than drop.

The third card fact: the sibling arms do NOT share the shape

ActionEngineFacade declares exactly four members, and only one takes an options bag:

insert(object, data)            update(object, id, data)
delete(object, idOrIds)         find(object, query)   ← the only bag

There is no findOne and no count on this facade. The write doors have nowhere to carry a context at the type level, so there is nothing to price and nothing to widen this diff onto. Reported as measured, per the order.

What changed

  • packages/spec/src/ui/action-params.zod.ts — the declaration. find(object, query: Omit(EngineQueryOptions, 'context')), plus the member doc rewritten: why the key is gone, and the asymmetry it leaves.
  • packages/spec/src/ui/action-params.test.ts — finding(spec): ActionEngineFacade.find's FilterCondition slot still admits the ObjectQL envelope { where: … } at compile time — closing the bar is a vocabulary claim (no field named where) the spec does not declare #15124's identity pin retargeted to the narrowed shape; a second pin that reds only when context becomes writable again; a value-level refusal pin with a positive control.
  • packages/runtime/src/action-execution.ts — comment only, zero behaviour. The arm's docblock now states that the type no longer admits the key while this arm still does, and why closing that half is not a type narrowing's business.
  • content/docs/ui/actions.mdx — the callout gains the one subtraction.
  • Generated: none. This PR originally regenerated api-surface-declarations/ui.txt; main deleted that whole artefact family (17 shards) in 2277d1fcd10, so the regeneration was dropped in the merge. The artefact that replaced it, api-surface-signatures.json, does not move for this narrowing — gen:api-surface rewrites it byte-identically (blob b2099d11828), because it hashes checker.typeToString() of the 27 defineX factories, which prints a type reference without expanding it.

The pin is TYPE-level, and that is deliberate

FindQueryCarriesNoContextKey asserts the key is absent from the declared slot; the two @ts-expect-error directives red if a literal carrying context starts compiling. A runtime pin would assert a refusal that does not exist and must not: adding one makes the facade throw on an identity key, which is a runtime permission change no ruling covers. The runtime's own pin is untouched and still green.

The file is inside the checked zone — check:test-typecheck reports packages/spec/tsconfig.test.json compiling 54 files — so these are not phantom directives.

Reverse verification

Fix committed first, then the declaration alone reverted to EngineQueryOptions:

  • on-disk proof — narrowed spelling 1 → 0, widened 0 → 1, blob 3f73de3ae0f → 58f5dc6b90e; a no-op edit would have been caught here and the reading voided.
  • ablated pnpm --filter @objectstack/spec typecheck → exit 1, src/ui/action-params.test.ts: 4 type error(s) — the two asserts plus the two now-unused @ts-expect-error directives.
  • restored with git checkout HEAD -- PATH (never a bare checkout, which reads the polluted index): git diff HEAD empty and git hash-object back to 3f73de3ae0f, byte-identical. A trap on EXIT/INT/TERM carried the restore, with an absolute repo root.

Direction predicted before the run and observed: red.

Verification — per consumer package, on the merged head d55c3d9e772

Package Reading
@objectstack/spec 500 files / 14644 tests passed · typecheck exit 0
@objectstack/runtime 268 files / 3705 passed, 1 skipped · typecheck exit 0
@objectstack/objectql 300 files / 5009 passed · typecheck exit 0
@objectstack/example-todo 7 files / 238 passed · typecheck exit 0

@objectstack/objectql is not on the dispatched floor list: the census found it, at packages/objectql/src/engine-write-not-found-gate.test.ts, which builds a real facade through buildActionEngineFacade. Run because it is a consumer, and said so.

⚠️ One reading was thrown away rather than reported: the first runtime run answered 242 test files failed / 7 tests failed, which was Cannot find package on an unbuilt dependency closure — PREREQUISITE NOT MET, not a red. Re-run after pnpm --filter '@objectstack/example-todo^...' build and reported above.

Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, derived from this tree, re-derived after the merge (same 107, no families added or dropped): 107 of 107 green, each exit code redirected to its own file and read back before any pipe, then reconciled with --ran carrying the codes — 107 run, 0 NOT-MEASURED (a DERIVED zero).

Two needed a second run, and both were prerequisite misses rather than reds: check:skill-examples (exit 1, packages/client-react/dist unbuilt) and check:dual-build-cjs-loads (exit 3, its own PREREQUISITE NOT MET — ⛔ This is NOT a pass). Both green after building the missing packages.

check:pm-widening-tells is green — the T1 tell that card #19099 records against this shape did not fire, so the Clause-②: no declaration needed no over-declaring to get past a gate.

Lint, repo-wide rather than narrowed: eslint . --no-inline-config over all 6916 files eslint's own config judges — 0 errors, 0 warnings, exit 0, at d55c3d9e772. The file count is read from eslint's own --format json output, not estimated. No type-aware linting is configured (eslint.config.mjs states it carries no parserOptions.project and no typed rules), so nothing in this diff can move an untouched file's verdict.

Declaration

Clause-②: no — this puts no new key on a published payload; it removes one from a parameter type. The lane charter's line that a narrowing does not trigger clause ② is the criterion, and check:pm-widening-tells agrees with it mechanically. The changeset separately carries Clause-②: no (narrowing), which is signal (4) to check-adr-0087-registration: an accept-set narrowing on a published type is exactly what #16421 built that signal for, so it is declared rather than left to prose, with an already-registered disposition naming action-engine-facade-find-query-envelope — the entry #19223 landed, which already tells an upgrader that a caller-supplied context is ignored. That gate is green.

Acceptance notes

Noted, not filed — the asymmetry this leaves, stated so nobody reads it as an oversight. After this diff the facade's find arm refuses (at runtime) every top-level key the envelope does not carry, accepts-and-honours the ones it does, and accepts-and-overrides exactly one: context, for untyped callers only. Closing that last cell means a runtime refusal on an identity key — the maintainer's floor, not a dev's and not a seat's, and the dispatch prohibited taking it here. It is recorded on both halves of the contract (the spec member doc and the runtime arm's docblock, the latter with an explicit "do not finish the job here without a ruling"). Carrier: whoever holds the next ruling on this surface — there is no PR or person this file is waiting on today, so it is written down where the next editor of either half will read it, rather than filed as a card nobody is dispatched to.

⚠️ Not a finding, but worth one line for the next census on this surface: a receiver-name heuristic over .engine. is not sound here — 10 of the 11 context writers it flags are the data engine, which honours the key. Only a type or construction anchor (buildActionEngineFacade, or the findOne/aggregate members the facade lacks) separates the two populations.


Generated by Claude Code


Generated by Claude Code

…envelope

The facade is trusted and mints its own elevated ExecutionContext, which the
runtime spreads last — so a caller-supplied `context` was overridden, never
honoured, while the parameter type went on declaring it. That is ADR-0049's
declared-but-unenforced shape on the one key that carries identity and tenant.

Take the remove arm, at the declaration layer only: the parameter is now
`Omit<EngineQueryOptions, 'context'>`. No runtime behaviour changes — the
facade arm still accepts the key from the untyped channel and still overrides
it, because refusing an identity key there is a runtime permission change no
ruling covers. The asymmetry is recorded on both halves rather than closed.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
…d slot

One shard, one semantic hunk: `find(object, query: EngineQueryOptions)` becomes
`find(object, query: Omit<EngineQueryOptions, 'context'>)`, plus the member doc
that states why. No declaration-emit ORDER churn in the other seven shards —
this diff adds no import, so the d.ts chunking does not move.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 20, 2026
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec, touching 2 documentable anchor(s).

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

  • content/docs/ui/actions.mdx (via ActionEngineFacade (symbol, a top-level interface))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 141 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 32708262d787c4a151dbeba2991b287b175df958 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 32708262d787c4a151dbeba2991b287b175df958

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d55c3d9e77279677b81cf3beb5177d3e88229582

Isolated review for the domain:spec lane, PR #19315 against card #19237. Every reading below was taken in this act, in a detached worktree at the head sha, with origin/main fetched at 2026-09-20T10:57Z = e3b3cdd2df3. Nothing is inherited from the PR body or the dev report; where a dev number is echoed it is because my own instrument reproduced it. Notation: TypeScript generics are written with parentheses — Omit(EngineQueryOptions, 'context') — because the platform rewrites tag-shaped fragments.

① Derived judgments

1. The narrowing is real at the type level and it bites — measured, not read off the body.

  • tsc -p packages/spec/tsconfig.test.json --listFilesOnly (11:03Z): 1837 files in the program; packages/spec/src/ui/action-params.test.ts is in it (count 1); a fabricated sibling name counts 0. The directives are inside a program a script actually runs: pnpm --filter @objectstack/spec check:test-typecheck exit 0 (11:05Z), and packages/spec/test-typecheck-debt.json does not name action-params.test.ts (grep exit 1; lit control conversation.test.ts exit 0), so no ledger entry could absorb a red from that file.
  • Ablation, direction predicted red, observed red. With only the declaration reverted to find(object, query: EngineQueryOptions) (blob 3f73de3ae0f to 58f5dc6b90e, on-disk grep 1 to 0 / 0 to 1 before any result was read), tsc -p tsconfig.test.json reports exactly 4 errors in action-params.test.ts: TS2344 at :430 and :448 (the two type asserts) and TS2578 unused @ts-expect-error at :525 and :527. Restored with git checkout HEAD -- path; blob back to 3f73de3ae0f, git diff HEAD empty (11:06Z).
  • vitest on src/ui/action-params.test.ts under the local project: 34 of 34 pass (11:06Z).

2. Narrowing, not widening — the accept set re-derived by a compiled probe, not by reading the diff. A probe file compiled under the same test tsconfig held all four assertions: the key set removed from EngineQueryOptions on the facade slot is exactly 'context'; the key set added is never; no string index signature leaked in (the classic Omit-over-index-signature collapse did not happen, because EngineQueryOptionsSchema at data-engine.zod.ts:98 is a plain object extend with no passthrough or catchall); and the slot is identical to Pick of the engine type over its other keys, so every surviving key keeps the engine's property type by reference. The compiler printed the surviving union: where, fields, orderBy, limit, offset, top, cursor, search, searchFields, expand, distinct (11 keys) versus the engine's 12. check-widening-tells --declaration no over the diff: 0 tells (1 file judged, 5 not measurable because they are not contract sources). ⇒ Clause-②: no with the (narrowing) arm is the honest declaration; nothing in this diff widens anything.

3. Mechanism and the runtime half, re-traced on head. packages/runtime/src/action-execution.ts:1636 is ql.find(object, { ...(query ?? {}), context } as any) — the facade's own context (minted at :1560) is spread last, so a caller value is overridden, not dropped. findEnvelopeKeys() at :1495-1498 reads its legal set off EngineQueryOptionsSchema.shape, which still carries context from BaseEngineOptionsSchema (:73), so the untyped channel is accepted and overridden rather than refused. The runtime hunk is 24 changed lines and every one is a comment line (a grep for a non-comment changed line exits 1) — zero behaviour change is a measured fact here, not a claim. The interface at head declares exactly four members and only find takes a bag (read off the interface body), so there is no sibling arm this narrowing should have reached. The runtime pin ran here too, after building the dependency closure in this worktree: packages/runtime/src/action-engine-facade-find-envelope.test.ts 9 of 9 pass (11:12Z), including stamps the facade's OWN elevated context, and a caller-supplied context does not displace it — the accept-and-override behaviour the new docblock describes is what the runtime does at head.

4. Published surface. packages/spec/package.json files[] ships src/**/*.zod.ts and api-surface-declarations, so both action-params.zod.ts and the regenerated ui.txt are tarball sentences. The new sentences are true on today's tree: the override line, the schema-derived legal set, and the four-member shape are each the code I read above. No copy of the old claim survives: a grep over packages/spec and content/docs for the three retired sentences (the "by identity" clause, the "caller's to pass" heading, the "reads as authorization" line) exits 1, with the replacement sentence as lit control (exit 0, found in the zod file and in ui.txt) and a fabricated sentence as dark control (exit 1). Only the ui.txt shard moved in the declarations directory. Regeneration is byte-exact: a full @objectstack/spec build with declarations (exit 0, 11:11Z) followed by pnpm --filter @objectstack/spec check:api-surface-declarations reports declaration text unchanged ✓ (17 entry points, 5364 declarations), exit 0, and git status is empty afterwards — the committed ui.txt is what the build emits, not a hand edit. CI's Type Check · consumer gates job, which runs the same check, is green on this head.

5. Changeset and ADR-0087 signal (4), re-derived. The changeset bumps only @objectstack/spec at minor, carries the line-initial Clause-②: no (narrowing), exactly one adr-0087: marker (not-required (already-registered action-engine-facade-find-query-envelope)), and a FROM → TO migration table. I ran node scripts/check-adr-0087-registration.mjs twice — --base e3b3cdd2df3 (current origin/main) and --base adf4b18777d (this head's actual merge-base) — exit 0 both times, classifying the changeset [clause-②-narrowing], i.e. signal (4) fired as designed and the disposition was validated against facts: the named entry resolves at head and already exists at both bases (registry.ts grep count 1 at adf4b18 and at e3b3cdd2). check-changeset-no-major exit 0 at both bases. The disposition is honest on the semantics too: the registered entry already tells an upgrader the envelope shape and that a caller-supplied context is ignored, the only consumer-side act this diff adds is deleting a key that entry already declares inert, and the changeset body itself ships the one-line fix into CHANGELOG.md. @objectstack/runtime owes no changeset because its diff is comment-only (judgment 3).

6. The census the premise rests on — re-taken with my own instrument. Receiver-chain regex plus a balanced-paren scan of the second argument plus a top-level context key test, over 6912 code files (ts/tsx/js/mjs/cjs/jsx/mts/cts) of 9035 tracked at head. Readings: 421 find(...) sites write a top-level context; by receiver they are ql 139, this.engine 125, bare engine 124, this 10, ctx.ql 5, data 5, the rest at most 2 each; the receivers ctx.engine and context.engine carry 0 such sites (22 ctx.engine.find( sites exist and none writes the key). Of the bare-engine rows (83 file-and-receiver groups), exactly one file binds engine from buildActionEngineFacade: packages/runtime/src/action-engine-facade-find-envelope.test.ts:126, the pin that asserts the key is NOT honoured — my lit control, and the same single site the dev found. Every other bare engine binds a data engine (new ObjectQL(), deps.getDataEngine(), kernel.getService('objectql'), makeEngine(...), a service-class field, or an engine-typed parameter), classified by reading each binding. Indirect handles: outside the objectql engine-rig tests and the memory driver, every ctx.engine use is a direct member call — the facade is never assigned to a variable or passed as an argument, so no helper writes the key on its behalf. Dark control: the same instrument with a fabricated member (findZZZQ) returns 0 sites; a second instrument with a fabricated member and builder returns 0 on every arm, exit 1. Sibling radius: objectui at the pinned .objectui-sha 53ded82bf7a, read-only git grep -q for ActionEngineFacade, ActionHandlerContext, ctx.engine. exits 1; lit control ObjectStack exits 0; dark control exits 1. ⇒ the premise holds: zero live call sites write a context on a facade query; triage's p0 trigger does not fire; priority:p1 stands.

7. Head, base and collisions — one correction to the brief I was handed. The PR API reports base.sha = e3b3cdd2df3 = current origin/main, mergeable: true, mergeable_state: blocked. But the head's merge-base with origin/main is adf4b18777d: main is 6 commits / 21 files ahead of this head, so the head is not a descendant of current main and the landing tree will be the queue's merge of the two. I diffed those 6 commits against this PR's surface: nothing under packages/spec, packages/runtime or content/docs/ui moved; the only overlap is four unrelated new changesets. The ADR-0087 gate is green at both bases (judgment 5). Across all 36 open PRs, none touches action-params.zod.ts, action-params.test.ts, action-execution.ts or data-engine.zod.ts; the generated api-surface-declarations/ui.txt shard is also regenerated by #19090 (draft) and deleted wholesale by #19024 (open) — a regeneration collision, not a source one.

8. Hygiene. Draft PR; first body line Fixes #19237; line-initial Clause-②: no; no model identifier in title, body, changeset, diff or commit trailers (model-free pair on both commits); no governed-surface path in the six-file diff; check-clause2-carriers --pair 19315 exit 4 at 11:04Z for the one reason this comment exists (C6, no record of record on this head yet).

② Semver level

minor on @objectstack/spec is the correct grading. The declaration arm (narrowing) is BREAKING by AGENTS.md's own line, and the launch-window guard (check-changeset-no-major.mjs, in force at head and green on this diff) forbids major and ships breaking as minor — so minor is not an under-declaration, it is the level the guard prescribes. @objectstack/spec is published (no private, files[] populated), so the unpublished escape was neither available nor claimed. No second package bump is owed.

③ Boundary flags

  • For the seat — CI at posting time. Check runs on d55c3d9e772, latest per name, read at 2026-09-20T11:19:33Z: 35 distinct names, 31 success, 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in) — all skipped by their own path or opt-in conditions, none a failure), 0 failed, 0 in progress — Lint & Repo Gates was the last to finish, at 11:19:08Z. Nothing was still running when this record was posted.
  • For the seat — landing tree. This head does not contain the 6 newest origin/main commits (judgment 7). Nothing on main since adf4b18777d touches this PR's files, so a merge-queue landing is safe as measured; if you want the tree you tested to be byte-identical to the tree that lands, re-merge origin/main first.
  • For the seat — generated shard collision. packages/spec/api-surface-declarations/ui.txt is also moved by spec(ui): BulkActionParamSchema is strict and declares dependsOn #19090 and by revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #19024. Whichever lands second must regenerate through scripts/pm/os-regen-merge.sh, never resolve textually — exactly the dispatch's own instruction.
  • For the seat, optional and non-blocking. content/docs/ui/actions.mdx:185 still opens the paragraph with "The parameter is typed EngineQueryOptions" before the new paragraph subtracts the key. The docs page is not in files[], so it is not a tarball sentence; one clause could say "minus context".
  • Not routed anywhere — recorded, agreed. The asymmetry the dev names (typed callers get a compile error, untyped callers still have the key overridden silently) is a runtime refusal on an identity key, the maintainer's floor by the dispatch's own prohibition. It is written on both halves of the contract at head; I see nothing for this lane to file.

Implemented-by: claude/issue-19237-facade-find-context-declared-unenforced
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 20, 2026 11:25
@os-litant
os-litant added this pull request to the merge queue Sep 20, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 20, 2026
@os-litant
os-litant added this pull request to the merge queue Sep 20, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 20, 2026
claude and others added 2 commits September 20, 2026 13:28
…d-unenforced

One conflict, resolved by accepting main's deletion:
packages/spec/api-surface-declarations/ui.txt.

The whole api-surface-declarations/ directory (17 shards) was removed
repo-wide by 2277d1f, which restored packages/spec/api-surface-signatures.json
in its place. This branch regenerated the ui.txt shard; that regeneration is
obsolete, so the file is deleted rather than resurrected. No source change.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b94b2b4b6af106472a91fe42f718e24324d1c54c

This is a merge-delta review, not a full one. It covers what the 2277d1fcd10 conflict round could have moved since the at-tier PASS at d55c3d9e772 (comment 5749469600), and nothing else. Every reading below was taken in this act, in a detached worktree at the head sha, against the PR base 32708262d (the head's merge-base with origin/main at 14:34Z). Not re-derived here, because their inputs did not move (judgment 3 says how that was checked): the compiled accept-set probe, the reverse ablation, the 421-site census instrument, the runtime pin run, the per-consumer suites, repo-wide lint, the gate sweep. Notation: TypeScript generics are written with parentheses — Omit(EngineQueryOptions, 'context') — because the platform rewrites tag-shaped fragments.

① Derived judgments

1. The conflict resolution is right: accept the deletion, do not resurrect ui.txt. git ls-tree -r finds 0 paths under packages/spec/api-surface-declarations/ at head and at the base; dark controls 2277d1fcd10^ and d55c3d9e772 find 18 each (17 shards plus the build script). The delta base→head is exactly 5 files (+159 / −19) and greps 0 for api-surface-declarations (grep exit 1; lit control: the deletion commit's own diff greps 105). The branch's two non-merge commits are the originals (af914ea, aa22b13); the ui.txt regeneration in aa22b13 is neutralised by the merge, not rewritten. All five files are blob-identical across d55c3d9e772 → a0e1ff541d1 → b94b2b4b6af (action-params.zod.ts at 3f73de3ae0f), with .changeset/17508-repeater-row-property-localisation.md as the instrument control: absent at a0e1ff541d1, present at head. packages/spec/package.json files[] at head no longer lists api-surface-declarations; it still lists src/**/*.zod.ts, so the rewritten member doc remains a tarball sentence.

2. api-surface-signatures.json does not move for this narrowing — re-derived, both directions, with a lit control. In the worktree: pnpm install --frozen-lockfile exit 0; a full @objectstack/spec build with d.ts, exit 0 at 14:37:23Z (46 .d.ts in dist, git status empty). check:api-surface exit 0 — public API surface + factory signatures unchanged ✓. gen:api-surface exit 0 — 17 entries, 5364 exports … 27 factories; git hash-object before and after: b2099d11828, git status --porcelain empty. Lit control: one hash mutated (defineAction → sixteen zeros, exactly 1 replacement, blob 904afff0672) — check:api-surface exit 1, signature changed: defineAction … 1 breaking (removed/narrowed); gen:api-surface then restored blob b2099d11828, status empty. Why it cannot move: build-api-surface.ts hashes checker.typeToString() of the root entry's 27 define* function exports — a type reference, never expanded (its header cites #3883 as the precedent) — and ActionEngineFacade is an interface, not a factory, so its member's parameter type is in no hashed string. The breadth shard api-surface/ui.json records ActionEngineFacade (interface) (grep 1; dark control 0) and existence is unchanged. Both directions: the committed blob on main at the base and at ada701220b1 is the same b2099d11828, produced from a tree without the narrowing; head's tree with the narrowing regenerates it byte-exact. ⇒ no stale generated artefact ships. The file is routed merge=os-regen in .gitattributes, but neither side of this PR touches it, so no driver ran.

3. The earlier PASS still holds; here is what expired, what moved, and what did not.

  • Expired: judgment 4's check:api-surface-declarations leg and its ui.txt tarball-sentence half — the gate script is deleted and files[] no longer lists the directory. Judgment 2 above replaces it.
  • Moved, re-run: scripts/pm/check-widening-tells.mjs (the deletion commit put api-surface-signatures.json back on its T3 surface) — --self-test 508 cases exit 0; over the 5-file diff at head with --declaration no: 0 tells, 1 judged / 4 NOT MEASURED, exit 0. The merge-base moved from adf4b18777d to 32708262d — check-adr-0087-registration.mjs --base 32708262d exit 0, [clause-②-narrowing] not-required (already-registered); check-changeset-no-major.mjs exit 0 at that base.
  • Unmoved by blob between d55c3d9e772 and head: the five PR files, data-engine.zod.ts, action-engine-facade-find-envelope.test.ts, migrations/registry.ts and entry 18 action-engine-facade-find-query-envelope, tsconfig.json, tsconfig.test.json, test-typecheck-debt.json, the ADR-0087 and no-major gate scripts. The compiled accept-set probe, the reverse ablation and the runtime pin read those bytes and are not repeated.
  • Census population: main moved 98 code files between d55c3d9e772 and head; none contains buildActionEngineFacade, ctx.engine. or context.engine. at head. Files naming buildActionEngineFacade: 14 → 13, the one loss is ui.txt. ctx.engine.find( sites: 38 → 35, exactly the 3 doc examples inside the deleted shard; the per-file diff excluding ui.txt is empty. ⇒ the zero-live-site premise is untouched.
  • Forward half re-measured on the merged head: pnpm --filter @objectstack/spec typecheck exit 0 at 14:39:09Z (tsc, scripts, and check:test-typecheck: 54 files; action-params.test.ts absent from the debt ledger, grep exit 1, lit conversation.test.ts exit 0); vitest src/ui/action-params.test.ts 34 of 34 pass.
  • Retired-sentence absence re-taken at head over packages/spec and content/docs: the three retired sentences grep exit 1 each; lit (the replacement sentence) exit 0 in the zod file and the docs page; dark exit 1.
  • Hygiene: no governed path in the delta (grep exit 1); no model identifier in commit messages or delta (grep exit 1; lit: 3 Claude-Session trailers); the head merge commit is a plain merge by the PM's account with no agent trailers. The PR is now non-draft — state set by another actor, read and not corrected.

4. The PR body matches the diff. Every sentence naming a generated artefact is true as measured: 17 shards, 2277d1fcd10, 27 factories, checker.typeToString(), blob b2099d11828, Generated: none. The changeset never mentions the declarations family. Three body readings are dated to older trees and remain true as labelled, not as descriptions of head: (a) the per-consumer suites, the repo-wide lint and the gate sweep are dated d55c3d9e772; (b) the same 107, no families added or dropped sentence describes a roster from before the deletion commit dropped the check:api-surface-declarations family — at head dispatch-gates.mjs --commands derives 105 commands and names no api-surface-declarations; (c) the mechanism line :1620 is tied to 1739f71879f, and at head the same statement sits at :1636. None is false; (b) is the one a reader might mistake for a head reading.

5. CI on this head, latest per name. Check runs on b94b2b4b6af, latest per name, read at 2026-09-20T14:44:05Z: 33 distinct names, 22 success, 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in) — each by its own path or opt-in condition, none a failure), 0 failed, 7 in progress at posting time: Lint & Repo Gates, Test Core (1/6), Test Core (3/6), Test Core (4/6), Test Core (5/6), Test Core (6/6), Type Check · workspace. The at-tier record was posted with 0 in progress; this one is not — the seat reads these to completion before arming.

② Semver level

Unchanged from the earlier record and re-measured on this head: minor on @objectstack/spec with line-initial Clause-②: no (narrowing) and exactly one adr-0087: marker in the changeset; the launch-window guard (check-changeset-no-major.mjs, exit 0 at the current base) forbids major, so minor is the prescribed level for a declaration narrowing, not an under-declaration. No second package bump is owed: packages/runtime/src/action-execution.ts is blob-identical to the head the earlier record measured as 24 comment-only lines.

③ Boundary flags

  • For the seat — landing tree. origin/main moved to ada701220b1 (feat(spec): the /packages doors declare the query parameters they execute, and retire the two they never did #19364) at 14:40Z, one commit past this PR's base; it touches 11 files, 0 overlapping this PR's five. A driverless three-way merge-tree of head onto it is clean (exit 0, tree 19f9a78da77; no path is changed on both sides, so no os-regen driver was involved and the answer is GitHub's). The ADR-0087 entry this changeset cites is present there (grep 1), its moved test-typecheck-debt.json still does not name action-params.test.ts, and api-surface-signatures.json is the same blob b2099d11828. Head is no longer a descendant of main; the queue's rebuilt generation validates the merge, and nothing measured here predicts a conflict.
  • Void. The earlier record's ui.txt collision flag (spec(ui): BulkActionParamSchema is strict and declares dependsOn #19090 / revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #19024) — the file no longer exists in this PR or on main, and the PR touches no generated artefact.
  • Optional, unchanged, non-blocking. content/docs/ui/actions.mdx:185 still opens The parameter is typed EngineQueryOptions before the new paragraph at :192 subtracts the key.
  • Not routed anywhere — unchanged. The typed/untyped asymmetry is recorded on both halves of the contract at head, exactly as the earlier record accepted it.

Implemented-by: claude/issue-19237-facade-find-context-declared-unenforced
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

@os-litant
os-litant added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 61dd96f Sep 20, 2026
46 checks passed
@os-litant
os-litant deleted the claude/issue-19237-facade-find-context-declared-unenforced branch September 20, 2026 15:33
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rd package body stages, and stop the record under-reporting functions (objectstack-ai#19373)

Fixes objectstack-ai#17518

Clause-②: yes

Executes ruling **A′** — decision batch objectstack-ai#192 item 3, comment 5748934194,
maintainer 「192 同意」. Its two steps, its refusals (A and B) and its
fences are followed as written; every place where the tree made me read
the ruling rather than transcribe it is called out below.

Base of every reading in this body: regeneration commit `96dd3549ff6`,
the head of the SIXTH merge.

> ⚠️ **The readings below were brought to this head by the seat, not by
the round that first wrote them.** Two merge rounds have run since the
first draft. Each figure corrected here is named in the correcting
round's own report on card objectstack-ai#17518 — comment 5750725852 for the first,
5750987577 for the second — and the seat re-verified the head, the
regenerated index and mergeability itself before editing. Anything not
listed in those two reports is the original round's reading, unchanged.

## The confidence gap the ruling asked me to close first

「whether `effect` is required or defaulted on the declaration schema —
read it, ⛔ do not mint a value」

**Defaulted.** `FlowFunctionDeclarationSchema.effect` is
`FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT)` where
that constant is `'pure'` (`automation/flow-function.zod.ts`). Measured,
not read off the source alone:
`FlowFunctionLoweredDeclarationSchema.safeParse({ handler: 'x' })`
succeeds and yields `{ handler: 'x', effect: 'pure' }`. The array member
of `functions` states `FlowFunctionEffectSchema.optional()` with **no**
default, so the two forms differ and neither is restated anywhere in
this diff — each JSON stage inherits its form's own optionality by
deriving from it.

That reading is what the producer writes: the bare-callable
normalisation uses `DEFAULT_FLOW_FUNCTION_EFFECT` and the array form
gets nothing.

## What landed

**`packages/spec/src/automation/flow-function.zod.ts`** —
`FlowFunctionLoweredDeclarationSchema` is exported (step 1), with its
`FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a
module-local `const`, and `automation/index.ts`'s `export *` only
re-exports what is already exported.

**`packages/spec/src/stack.zod.ts`** — two new bodies **beside**
`AssembledPackageBodySchema`:

- `ArtifactStagePackageBodySchema` — the on-disk artifact stage.
`functions` entries are the lowered spellings, `hooks[].handler` is a
string.
- `RecordStagePackageBodySchema` — the registry record stage: literally
`ArtifactStagePackageBodySchema.extend({ functions: … })` with
`functions[].handler` optional in both the map-record form and the array
form, and nothing else.

`AssembledPackageBodySchema`, `composeStacks` and the `cannot drift`
invariant are ⛔ untouched: those callables are live on the stage the
assembled body declares itself for, and narrowing it would refuse a
published composition function's own output. Both new schemas carry the
same structural `z.ZodType` annotation as the assembled body, for the
two reasons recorded there (TS7056; a named alias turning `stack.zod`
into a shared chunk).

**`packages/spec/src/api/package-api.zod.ts`** — the installed-package
row's `manifest` is rebound to the record stage (step 1). The
`z.unknown()` override and the docblock defending it are gone, and the
sentence that ruling A step 5 assigns to this edit is corrected in
place: those two members are **not** why `ArtifactPackageSchema` and
`ObjectStackDefinitionSchema` publish no JSON Schema —
`src/stack.zod.ts` is not one of the subpath namespaces
`build-schemas.ts` walks, so neither is ever reached by the emit loop.

**`packages/objectql/src/registry.ts`** — step 2.
`withDeclaredFunctionEntries` rewrites a bare callable `functions` map
entry to `{ handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT }` at the
assembly boundary, before `toRecordManifest` runs. `toRecordManifest`'s
structural rule is ⛔ untouched and no key is special-cased inside the
projection; the two spellings are simply made structurally equal ahead
of it. ⛔ No ref is minted, ⛔ no entry is dropped. The caller's manifest
is never mutated and a copy is made only when an entry really needed
rewriting.

## Two places where I read the ruling rather than transcribed it — both
stated so they can be overruled

1. **「`functions` entries the lowered declaration」 is implemented as
BOTH lowered members of `FlowFunctionEntrySchema`**, not only the record
one. `objectstack build` emits `{ myFn: 'myFn' }` for a bare entry and
`{ myFn: { handler: 'myFn', effect } }` for a declared one, so a stage
admitting only the record form would refuse artifacts this repo really
writes — the failure mode that withdrew letter B, one key across. Ruling
A′'s own step-4 control names both shapes (「a string and a lowered
record」). Measured: the artifact stage accepts a body carrying one of
each.
2. **The array member is transcribed, not derived.** `functions`' array
branch is declared inline inside the assembled body's own shape, and
narrowing it in place is the one thing this pair may not do. The
transcription's drift is guarded instead:
`stack-json-stage-package-body.test.ts` pins the authoring array entry's
key set equal to both JSON stages', so a key added there and not here
reddens by name.

## Acceptance, as ruling A′ lists it

| criterion | result |
|---|---|
| both bodies convert under `z.toJSONSchema` (self-test over the whole
body) | **YES** / **YES**; control: the assembled body still **NO**
(`Function types cannot be represented in JSON Schema`); probe controls
lit `z.string()` YES, dark `z.object({a: z.function()})` NO |
| the showcase-shaped manifest (`config.ts:244-249`) reports **2**
functions on the `GET /packages` row, the bare one as a handler-less
declaration | **2**:
`{"summarizeCompletedTask":{"effect":"pure"},"sweepProjectHealth":{"effect":"writes"}}`,
driven through the real `SchemaRegistry.installPackage` |
| `hooks` unchanged | unchanged: an inline handler is dropped (the key
is optional and admits that), a string handler survives verbatim. The
array `functions` form also keeps its entry:
`[{"name":"syncBilling","effect":"writes"}]` |
| `AssembledPackageBodySchema` / `composeStacks` / the invariant
untouched | untouched — no edit in those regions;
`assembled-package-body.test.ts` and
`compose-stacks-manifest-preserve.test.ts` stay green |
| the two `noted, not filed` corrections in the same edit | baseline
reason line: made TRUE by step 1 rather than reworded —
`automation/FlowFunctionLoweredDeclaration` is now in
`json-schema.manifest/automation.json`, so 「the lowered record …
publishes normally」 is now a fact. `package-api.zod.ts` docblock last
sentence: corrected in place, see above |

Stage separation, measured rather than asserted: the record stage
accepts the handler-less declaration and the **artifact** stage refuses
it; the assembled body accepts a live callable and **both** JSON stages
refuse it; both JSON stages still refuse an authoring glob and an
unknown key (`namesapce`). So the two keys moved from `unknown` to a
declaration, and nothing else moved.

## Reverse verification — two ablations, each restored with proof

Both ran against committed code, each with a `trap` restore, an on-disk
landing proof (anchor `grep -c` before/after plus a blob-hash change)
and a restore proof (`git hash-object` back to the HEAD blob, `git diff
HEAD` empty).

- **A1 — remove the producer normalisation**
(`toRecordManifest(withDeclaredFunctionEntries(manifest))` →
`toRecordManifest(manifest)`; anchor 1→0, injected 1, blob `b0af60d7…` →
`17b7c93c…`): `registry-package-manifest-serializable.test.ts` goes **1
failed / 15 passed**, naming the exact defect — `expected [
'sweepProjectHealth' ] to deeply equal [ 'summarizeCompletedTask', …(1)
]`. Restored blob `b0af60d7…`, diff empty.
- **A3 — collapse the record stage into the artifact stage**
(`jsonStageFunctionsKey(true)` → `(false)`; anchor 1→0, injected 2, blob
`60c13b43…` → `822bed8e…`): **2 failed / 79 passed** across two files —
`record accepts the handler-less declaration; ⛔ the ARTIFACT stage
refuses it` and `parses a row carrying the residual the projection
really produces`. So the one-key difference that IS the fourth stage is
load-bearing in both packages' pins. Restored blob `60c13b43…`, diff
empty.

No ablation is offered for 「both bodies convert」: that claim already
carries its discriminating control inside the same test file (the
assembled body must NOT convert), which is a lit/dark pair rather than
an assertion about itself.

## Tests and gates

All through `scripts/pm/os-verify-lock.sh` with
`OS_VERIFY_LOCK_SLOT=issue-17518`, verdicts read from the wrapper's own
`VERDICT command-exit` line and never a bare `$?`; every exit code
captured before any pipe. Wall-clock figures in the logs are SHARED-BOX
seconds.

- `pnpm --filter @objectstack/spec test` — **513 files / 14971 tests
passed, 1 todo** — the FULL suite, re-run on this head because the sixth
merge carried 128 commits of base movement including breaking spec
changes
- `pnpm --filter @objectstack/objectql test` — **303 files / 5057 tests
passed**
- `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2`
over the package-door / artifact population, enumerated by a name match
on `packages/runtime` for `package` or `artifact` so the population is
reproducible — **39 files / 512 tests passed**. ⚠️ The first attempt
exited 1 in 2 seconds and is recorded as NOT a red: the paths were
repo-root-relative while `pnpm exec` runs at the package root, and the
repo's own guard said so in words (`FILTER SELECTED NOTHING — 39 of the
39 path(s) you named will run no tests`). Re-run with package-relative
paths for the reading above.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — exit 0; both test layers compile (spec **53 files / 257
errors / 142 pins**; objectql **40 / 234 / 65**, unchanged). ⚠️ The spec
ledger moved from 54 / 259 / 144 by main's objectstack-ai#19364 arriving in a merge, ⛔
not by this PR.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — both exit 0 on this head; the debt ledgers held shrink-only
(spec 53 files / 257 errors / 142 pinned signatures; objectql 40 / 234 /
65).
- `pnpm --filter @objectstack/spec build` exit 0 (34/34 declared `.d.ts`
present, `check-dts-references` resolved 378/378), and the whole
`@objectstack/runtime` dependency closure was rebuilt first, so nothing
below read a dist stale against 128 commits of main.

**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived from this tree, every command run
with its exit code written to a file, reconciled with `--ran`: **116
derived, 114 run, 2 NOT-MEASURED, 0 UNRUN**, and the tool's own verdict
line says so. **113 exit 0.** The two NOT-MEASURED are the tool's
DERIVED classification of an exit 3; a third measured nothing too, and
the tool cannot see it because its refusal code is 2. ⛔ None of the
three is a finding:

- `check:dual-build-cjs-loads` — exit **3**, its own `PREREQUISITE NOT
MET … ⛔ This is NOT a pass: nothing was measured` (66 packages have no
`dist`; it wants a whole-repo build).
- `check:type-check-debt` — exit **3**, same shape, same wording, wants
the full package closure built.
- `check-engine-split-ratio --days 90` — exit **2**, refuses on a
shallow clone whose oldest visible commit sits inside the 90-day window.
It says a ratio derived there would be 「real, plausible and WRONG」.

A fourth, `check:skill-examples`, first exited 1 on an unbuilt
`packages/client-react`; after building that package it re-runs
**green** — 258 prose examples type-check across 3 surfaces. Both
readings are stated here, and the reconciliation record carries ONE of
them — the green re-run — because the tool flags a doubly-recorded
family and says to make the record state one thing. The re-derivation on
the final head yields **116** families: `check:api-surface-declarations`
is gone (retired upstream by objectstack-ai#19024 mid-round) and
`check:gitlink-declared` is new, run green. No family is left unrun.

Ratchet families re-run after the last merge, on `96dd3549ff6`:
`check:generated` (all 15 artifacts up to date), `check:api-surface`,
`check:authorable-surface`, `check:export-origins`,
`check:declaration-map`, `check:docs`, `check:skill-refs`,
`check:entry-nameability`, `check:dual-source-exports`,
`check:spec-changes`, `check:spec-parsed-alias`,
`check:published-files`, `check:nul-bytes`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage` — all exit 0. Control characters: `grep
-naP` over every file I hand-edited returns nothing (exit 1).

## Generated artefacts in this diff, and why each moved

- `json-schema.manifest/automation.json`,
`authorable-surface/automation.json`,
`authorable-defaults/automation.json`, `api-surface/*`,
`export-origins/*`, `declaration-map/automation.json`,
`content/docs/references/**` — the new exports, regenerated by the
package's own `gen:` scripts. `authorable-defaults` records
`automation/FlowFunctionLoweredDeclaration:effect = "pure"`, which is
the confidence-gap reading in ledger form.
- `packages/spec/dropped-refinements.baseline.json` — four `api/*`
entries each gain one site (`…manifest.hooks.element.object`), counts
569 → 573. Cause: the record stage **declares** `hooks` where
`z.unknown()` declared nothing, so `HookSchema`'s `object` refinement
now reaches the runtime and not the published file. The ledger is
hand-edited by design and the build printed the exact delta.
- `skills/objectstack-platform/references/_index.md` — one generated
line listing `stack.zod.ts`'s exports.

## `skills/**` readings, and the landing tier

This diff touches `skills/objectstack-platform/references/_index.md`, so
the PR is **governed, Tier H** on its file list. ⛔ It stays a draft and
no AI seat merges, queues or arms auto-merge on it.

Both readings the skills rule requires, at merge base `c334ba0f3a6`:

- **changed file, whole file**: 41 lines before, 41 after — net **0**.
The diff is one regenerated line.
- **package total (sum of every `SKILL.md`)**: 6145 before, 6145 after —
net **0**.

`node scripts/check-skills-token-ratchet.mjs` exits 0 and classifies
this file as **generator-owned (measured, not ratcheted)**, so no
authored ceiling is charged.

## Clause ②, and the changeset is not one package's

`Clause-②: yes`, and two changesets because two published packages move:

- `@objectstack/spec` — **minor**. New exports, and the two
installed-package responses move from `z.unknown()` on `functions` /
`hooks` to declared JSON shapes. That is a narrowing on a published
declaration; what it does NOT withdraw is measured, on real producers:
the showcase shape, the array form and the already-lowered body an
artifact boot installs all parse.
- `@objectstack/objectql` — **patch**. `GET /packages` reports functions
it previously dropped. No API is added or removed; a read door stops
under-reporting. Grade it up if a payload gaining entries reads as minor
to the reviewer.

## Serial and merge state, re-taken by this seat

Changed-file map re-taken first-hand over all **33** open PRs (271 file
rows) rather than inherited. LIT control
`packages/spec/src/ui/action-params.zod.ts` resolves to objectstack-ai#19315; DARK
control `packages/spec/src/zzz-no-such.zod.ts` resolves to nothing.

- `packages/spec/src/automation/flow-function.zod.ts`,
`packages/spec/src/api/package-api.zod.ts`,
`packages/objectql/src/registry.ts` — **free**.
- `packages/spec/src/stack.zod.ts` — held by objectstack-ai#18482, objectstack-ai#19147, objectstack-ai#19314, all
below A′'s region. objectstack-ai#19147 landed during this round and merged cleanly
here (its `stack.zod.ts` hunk is a comment).
- `packages/spec/dropped-refinements.baseline.json` — also written by
objectstack-ai#19147 (landed, resolved here) and by the still-open **objectstack-ai#19335**, which
rewrites the same `measured` header and adds entries. That is a
line-level contention on a ledger whose correct value is recomputable:
whoever lands second re-runs `pnpm --filter @objectstack/spec build` and
re-applies the delta it prints. ⛔ Not a semantic collision.

`origin/main` has been merged **six** times on this branch. `objectstack-ai#19024`
(which retired `api-surface-declarations/`) came in early, which is why
no `api-surface-declarations/*.txt` appears in this diff. The fifth
merge brought **objectstack-ai#19363**, a BREAKING spec change. The **sixth** merge,
the head of this body, brought **128 commits** — so the full spec suite
was re-run rather than only the generated gates.

⛔ `scripts/pm/os-regen-merge.sh` was NOT used in either round — its
`rerun` arm is re-entrant and commits a revert of the operator's own
regeneration, filed as **objectstack-ai#19392**. Steps 1–3 of its documented order
were performed by hand, against a merge base captured BEFORE the merge
and an `origin/main` fetched into an OWNED ref so a sibling's fetch
could not move the target mid-round.

**The sixth merge decided THREE paths, and only one of them was a
conflict.** That gap is worth stating, because resolving only what a
conflict probe names would have landed a silent loss:

| path | routed | what the merge did | how it was resolved |
|:--|:--|:--|:--|
| `content/docs/references/index.mdx` | `merge=os-regen` | driver
deferred it, exit 0 — **main's side silently dropped** (merged blob
`6290447bd9a` == ours, != theirs `7e1f9b6f13e`) | main's side restored
into the WORKING TREE ONLY, then regenerated whole |
| `content/docs/references/api/package-api.mdx` | `merge=os-regen` |
same — **main's side silently dropped** (merged `988bedaa480` == ours,
!= theirs `d09cd420711`) | same |
| `packages/spec/dropped-refinements.baseline.json` | **not** routed |
exit 1 — the only real text conflict, one hunk, confined to three
summary counters in the `measured` header | both sides' entries unioned,
then the build adjudicated |

⚠️ **`package-api.mdx` appears in NO conflict list and never could.** It
text-merges cleanly driver-free, so a GitHub-condition probe cannot name
it; only the both-edited ROUTED set, computed per file against the
pre-merge base, finds it — which is exactly what `os-regen-merge.sh`
step 2 specifies and what the driver's own `$GIT_DIR/os-regen-pending`
record listed.

**The regenerated docs are the UNION, proven in both directions**
(added/removed line multisets compared as sets): `package-api.mdx`
identical at 20 and 14 lines; `index.mdx` identical at 12 and 6 lines,
excluding the two running-total lines — a union MUST move a total
neither side moves alone, so their disagreement is the signature of a
correct union rather than a failure, and the line counts already matched
(16/16, 10/10) before excluding them. The total is **re-derived, not
arithmetic**: base 1533, this branch alone 1534, main alone 1534, merged
tree **1535**, and 1535 is what `gen:schema` itself reports for the
merged sources. Main brought `DatasetSelection`, `DatasetCompareTo` and
`DatasetTotals` and retired `KernelSecurityScanResult` /
`KernelSecurityVulnerability`; this branch brought
`FlowFunctionLoweredDeclaration`. All survive, asserted through the
published export map of the freshly built dist with a dark control (an
invented export name reads undefined).

**The ledger was resolved by hand, and that is the only route
available.** `dropped-refinements.baseline.json` is hand-edited BY
DESIGN with no `gen:` script — its own description states why: *"a
generator would let a new gap be admitted by running a command instead
of by a decision, which is the silence this ledger exists to end."* The
build VALIDATES it bidirectionally and refuses; it never writes it. Both
sides' entries were unioned (union keys missing from the merged file:
**none**; merged keys not in the union: **none**; `api/DatasetSelection`
arrived from main via objectstack-ai#19638 and survives; main's removal of the
`fields.out.keyType` sites is kept — **nine** site lines at the merge
base, zero at this head and zero on main (lit control: 204 `"sites"`
keys at base; dark control 0). ⚠️ The merge round's own prose said
*five*; that was a narrative miscount caught by the merge-delta review
and re-counted by the seat. The FILE was always right), then
`gen:schema` adjudicated and measured 565 dropped sites across 205
published schemas — the union as resolved. One counter the build
corrected: `refinementSitesThatDidProject` read 357 and the build
measures 366.

⚠️ **That correction is filed as objectstack-ai#19681**, because nothing in the
repository would have caught it: two of the four `measured` counters
have no reader anywhere (lit control — the other two have two readers
each, dark control 0), so they can hold any number and every gate stays
green.

## Acceptance notes

- **noted, not filed**: regenerating
`packages/spec/api-surface-declarations/ui.txt` produced a 184-line
change that is a pure permutation of its own content — the same union
members in a different order, `0 removed, 0 added, 35 reshaped`.
Verified as a precedented shape rather than a defect: commit
`24d622b94b8`, a spec change touching **zero** files under
`packages/spec/src/ui/`, moved the same file by 5 lines whose sorted
content is byte-identical. The whole artefact was retired upstream by
objectstack-ai#19024 mid-round, so nothing of it survives in this diff and the
population is gone. **Carrier: none — the file no longer exists.**
- **noted, not filed**: `packages/objectql`'s tests resolve
`@objectstack/metadata-protocol` from `dist`, so after merging upstream
objectstack-ai#19277 the seven assertions in
`protocol-install-package-enable-on-install.test.ts` failed against a
stale build of a package this PR never touches; building that one
package turns all seven green. A local-environment reading, not a repo
defect, and `check:test-source-alias` already owns the aliased/unaliased
ledger this sits in. **Carrier: the next seat that runs objectql's suite
after a merge — it will see the same red and should build the dependency
before reading it as a finding.**

## 维护者速读(草稿)

**改了什么** —— 一个包的「包体」在平台里其实要经过四个阶段:作者写的、内存里装配好的、落盘成 artifact
的、注册表记录下来的。前两个早有声明,后两个从来没有。这次把后两个补上:`ArtifactStagePackageBodySchema`(落盘
artifact)和
`RecordStagePackageBodySchema`(注册表记录),放在既有的装配体**旁边**,装配体一个字不动。同时修好一个生产者缺陷:`GET
/packages` 以前会把「裸写的函数」整条漏报,现在两种写法都报。

**为什么改** —— 两件事各有代价。其一,装配体里有两个键(`functions`、`hooks`)声明了「可以是一个活的函数」,而
JSON Schema 表达不了函数,于是**任何嵌入它的接口都会整份丢掉自己的 JSON Schema**;读 API
只能把这两个键写成「什么都收、不检查」。其二,我们自己发布的 showcase 声明了 2 个函数,而 `GET /packages` 只报 1
个——机器可读的读门把事实说少了。

**风险与代价(含回滚)** ——
风险集中在一处:那两个键从「什么都收」变成「按声明收」,理论上可能拒掉今天能读的行。已实测三种真实生产者(showcase
的写法、数组写法、artifact 启动装回来的写法)全部照常通过,并且用两次消融证明了这些断言真的会红而不是摆设。⛔ 装配体与
`composeStacks` 未动,所以 `os dev` / `os serve` 的行为不受影响——这正是上一版裁决 B
被撤回的原因,这次没有重蹈。回滚:两个 spec 改动与 objectql 改动互相独立,`git revert`
任一半都不会让另一半变红;最小回滚是把 `package-api.zod.ts` 的那一行绑回装配体,新声明留着不用。

**席位意见** ——

**你要做的** —— 这个 PR 的文件里有一份 `skills/**` 的生成文件,按规则整单属于 Tier
H,**只有你(或你授权的批准)能让它落地**;AI 席位不会合并、不会排队、不会解除 draft。请看两点:①
`@objectstack/objectql` 我打的是 `patch`,理由是「读门修复、不增删 API」,若你认为「载荷多出条目」应算
minor,说一声即可改;② `functions` 的声明式阶段我按「两种 lowered 写法都收」实现(理由写在上面第 1
条),如果裁决本意是只收记录式那一种,也请直接说,那会让 `objectstack build` 今天写出的一种 artifact 被拒。

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

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

---------

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 protocol:ui size/m tests tooling

Projects

None yet

2 participants