Skip to content

fix(spec): declare the assembled manifest stage on the package read API - #17517

Merged
os-bill merged 8 commits into
mainfrom
claude/issue-17431-assembled-stage-package-response
Sep 10, 2026
Merged

os-bill merged 8 commits into
mainfrom
claude/issue-17431-assembled-stage-package-response

Conversation

@os-bill

@os-bill os-bill commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #17431

Clause-②: yes — this changes a PUBLISHED API type, and needs:contract-review is hung on the card and on this PR. Widens. Details in the section of that name below.

Status: both maintainer calls are answered and applied. The seat answered on this PR (comment of 2026-09-10T18:35Z): Q1 = A (keep the measured unknown override exactly as implemented) and Q2 = A (apply #11072's standing ruling to ./api), authorising the file-face growth into packages/spec/tsup.config.ts and packages/spec/package.json. Both are in. check:browser-reachable-entries is now exit 0, established with a dark control. This stays a draft pending the at-tier contract review on its final head — that review is the seat's, and it could not be taken while the gate was red.

The defect, re-measured on origin/main before anything was written

Driven against a real SchemaRegistry and the real dispatcher door, both premises reproduce exactly:

door package authored as safeParse(body)
GET /packages globs (objects: ['./src/objects/*.object.yml']) success
GET /packages defineStack() (objects: [{ name, fields }]) failure — issue list is exactly ['data.packages.0.manifest.objects.0'], invalid_type, expected string, received object
GET /packages/:id globs success
GET /packages/:id defineStack() failure — exactly ['data.manifest.objects.0'], same code

Exactly one issue at exactly that path, on both doors — a stage mismatch, not a general shape problem.

What this branch does

Follows the #14242 ruling one layer up — declare the assembled stage, never widen the authoring one. ManifestSchema is untouched: still strictObject, still globs.

@objectstack/spec/api gains two exports:

Why a union, and why that is not #14242's rejected road C

#14242 bound the artifact's packages[] to the assembled stage alone, and its stated reason is a property of that surface: "a glob in a compiled artifact names files nobody will read." The installed-packages table is not a compiled artifact — both stages reach it through declared doors:

  • PackageInstallRequestSchema declares manifest: ManifestSchema (authoring), and POST /packages hands that body straight to SchemaRegistry.installPackage, which stores a JSON projection of it;
  • a defineStack() host reaches the same table via ObjectQL.registerApp (assembled).

So a read contract naming only the assembled stage would refuse a row this API's own install contract is declared to produce, and the glob pin in the conformance suite would go red. Naming only the authoring stage is the defect being closed.

Road C was a union inside a key (objects: (string | ObjectDef)[]), which describes no stage and admits a mixed array. This union is over two complete closed declarations: every parse is a full parse of one coherent stage, and a row belonging to neither — a mixed objects array among them — is refused by both branches. That refusal is pinned through the real door, so "it accepts both" cannot quietly become "it accepts anything".

Call 1 — the assembled body has no JSON Schema · answered: A, unchanged

Binding AssembledPackageBodySchema verbatim made both response schemas vanish from json-schema/api/:

2 previously published schema(s) disappeared from this build:
     - json-schema/api/GetInstalledPackageResponse.json
     - json-schema/api/ListInstalledPackagesResponse.json

Measured cause: of the assembled body's 55 shape members, exactly two have no JSON form — functions (a z.function() branch) and hooks (a z.custom() branch). That is also why ArtifactPackageSchema and ObjectStackDefinitionSchema publish no JSON Schema at all.

This branch takes the remedy build-schemas.ts itself calls preferred — "make it emit — narrow the unrepresentable member" — declaring those two keys as unknown on the read-API record body only. The grounding is the producer: SchemaRegistry.installPackage stores toRecordManifest(manifest), a structural JSON projection that drops functions and exotic values, so the row is JSON by construction. The override set is measured, not hand-picked — established in both directions (omit functions, still fails on custom; omit hooks, still fails on function; omit both, OK) and pinned key-by-key in package-api.test.ts, so a new non-serialisable collection reddens there naming itself instead of silently unpublishing two response schemas again.

The seat kept A and refused B: retiring the two published JSON-Schema defs would trade two published schemas, their reference pages and an owed ADR-0087 D3 disposition for strictness about two keys a JSON record cannot carry in their declared shape anyway. The real repair is filed as #17518 and is out of scope here; when it lands, this override collapses to nothing and the key-by-key pin is what will say so.

What it costs, plainly: on this surface those two keys are accepted without being checked. Today both are refused outright while the door really can serve them, so the declaration moves from wrong to incomplete — never from checked to tolerant. Every other key, objects included, is checked at the assembled stage.

Call 2 — ./api is browser-reachable · answered: A, applied

Declaring the assembled stage makes this entry's module graph reach the driver-config validators. The edge is one import, and the chain is short:

packages/spec/src/api/package-api.zod.ts
  -> ../stack.zod                        (AssembledPackageBodySchema)
  -> ./data/datasource.zod               (DatasourceSchema)
  -> ./driver/config-registry.zod        (validateDriverConfig)
  -> ./driver/postgres.zod               (the pg URL refinement)
  -> ./driver/pg-url-grammar.server      (the one allowed import of pg-connection-string)

pg-connection-string's parse statically resolves require('fs'), so before this commit ./api — which declared no browser condition — was exactly the file a browser bundler loads, and it linked the parser in both format halves.

Per the seat's ruling this applies #11072 Option A (maintainer, 2026-08-22) to the newly affected entry: the ruling names "the affected entries" as a rule rather than an enumeration, and ./api is now one. Same seam, same swapServerOnlyGrammarArm swap, same dist/browser/ output — no second mechanism, and the four entries that already carry the condition are untouched.

The gate, in both directions

leg tree gate exit
before this branch, ./api with no browser condition 1 — 2 problems: ./dist/api/index.mjs and ./dist/api/index.js each link the Node-only specifier pg-connection-string
after this branch with the condition + tsup entry 0 — "5 browser-conditioned subpath(s), 10 server-only link(s) on their Node side — positive control held"
dark control after, with only the browser condition deleted from the exports map 1 — and for the rule's own reason ("declares NO browser condition"), not a freshness refusal

The dark control mutated the file on disk (the two dist/browser/api/index.* target lines went 2 to 0, asserted rather than assumed), and restoration was verified by blob hash against the HEAD blob plus an empty git diff HEAD — not by a git checkout exit code.

The browser pass really builds rather than silently no-op'ing: dist/browser/api/index.js 1.80 MB, dist/browser/api/index.mjs 1.77 MB.

What browser consumers now resolve — measured, not restated

Both bundles were loaded and the same AssembledInstalledPackageSchema parsed the same payloads through each:

authored config.url Node browser
postgresql://h1:5432,h2:5433/db (multi-host, pg grammar arm) REFUSED accepted
host=x dbname=y (libpq keyword/value, relative-to-base arm) REFUSED accepted
postgresql://db.example.com/app?sslcert=/etc/x.pem REFUSED REFUSED — identical message
/var/run/postgresql (unix-socket short-circuit) accepted accepted
postgresql://db.example.com:5432/app accepted accepted

That is exactly the degradation the ruling describes and accepted: the postgres URL refinement falls back to the shape-only checks it already performs before parse. The filesystem-reading query-parameter refusal and the unix-socket short-circuit are kept; only the "is this a URL pg can open" arm answers "no findings". Datasource publish is a server-side act, so that arm never legitimately ran in a browser.

Nothing else moved. The neighbouring refinements that are not swapped — credentialFreeUrl, placeholderFree, and the object-level strict close — return byte-identical issue sets from both bundles.

The Node side is untouched: mainConfig's entry list is unchanged, the ./api import / require branches are unchanged, and the node bundles are the same size before and after (dist/api/index.mjs 1.77 MB, dist/api/index.js 1.81 MB). Resolution was checked in both directions and both format halves:

default conditions   require -> packages/spec/dist/api/index.js
                     import  -> packages/spec/dist/api/index.mjs
--conditions=browser require -> packages/spec/dist/browser/api/index.js
                     import  -> packages/spec/dist/browser/api/index.mjs

Correction for the record: an earlier revision of this body said five entries carry a browser condition, citing #11072's dev report which listed ./cloud. Measured on origin/main and on this branch, it is four — ., ./data, ./system, ./kernel — and ./cloud is not an export at all. ./api is the fifth, added here.

Widens, narrows, or replaces — widens

  • Runtime: strictly widening. Every payload that parsed before still parses (the authoring branch is InstalledPackageSchema unchanged); payloads previously refused for their manifest stage now parse. Nothing that was accepted became refused. check:api-surface exit 0, and the surface diff is six added names with nothing removed or narrowed.
  • Static: a widening a reader must react to. ListInstalledPackagesResponse['data']['packages'][number] and GetInstalledPackageResponse['data'] become a union, so code reading .manifest.version off one of them must narrow first. Measured blast radius in-tree: zero — nothing in this repo reads those two types for field access, and packages/client's packages.list / packages.get are bound to the kernel InstalledPackage, which this PR does not touch.
    • What narrowing does and does not buy — re-measured after the at-tier review; the changeset now says this too. The assembled branch's manifest is annotated as a plain string-keyed record of unknown in stack.zod.ts (inherited, untouched here), so the union does not discriminate on that side. Measured against the built .d.ts, with a five-case dark control that all errored: a plain .manifest.version read yields unknown where it used to yield string; narrowing toward the AUTHORING branch restores ManifestSchema in full (version typed string, objects typed as an array of string); narrowing away from it yields the opaque record — every manifest field unknown. In the assignment direction the assembled branch admits any object at manifest, so a garbage manifest and the mixed-stage row both typecheck clean (tsc exit 0) while the runtime union REFUSES both (measured: REFUSED / REFUSED, with the two real stages ACCEPTED as the positive control). The runtime parse is the win; the static side is one-sided.
  • The exports-map change is additive too. A browser condition is a new branch; Node resolution is unaffected and no existing target moved. For browser consumers it is the accept-set widening tabulated above, which is the consequence [seam→spec] @objectstack/spec@17.1.0 statically imports pg-connection-string from six of its sixteen entry points, with no browser export condition — objectui's site build is red on main #11072's grounds weighed and accepted.
  • Not a replacement, and not a narrowing. No declaration was removed, renamed or tightened.
  • ADR-0087 disposition: not owed — nothing is retired, renamed or made lossy, and check:adr-0087-registration --base origin/main exits 0. It would be owed under Call 1's refused alternative, which is one reason that alternative was the maintainer's.

Ledger rows

Both /packages read rows carry a responseSchema again, legitimate only because packages-read-delete-response-conformance.test.ts drives those handlers and parses what they answer on both authoring paths. Each note records what its declaration does not carry: every row also serves writable, this door's own computed verdict, which a declared parse strips — asserted by name rather than fixed, the disposition its DELETE sibling already carries.

A later commit on this branch strips the internal tracker ids back out of those note: strings. check:doc-authoring refuses them there — a runtime note reaches authors, operators and generated surfaces, none of whom can resolve a bare id — and the earlier revision had moved five id references out of // comments and into the strings, taking that gate from green to red. The ids are back in the adjacent comment, which is their prescribed home; every note keeps its substance.

Face growth to report: the claim named the GET /packages row. This also fills GET /packages/:id, because the same declaration change lands on GetInstalledPackageResponseSchema and leaving a row blank while its coverage exists reads as withholding. Same file, same defect class, same gate family, no new verification surface. The seat separately authorised the growth into packages/spec/tsup.config.ts and packages/spec/package.json for Call 2.

Verification

Final head 5253cc3729c94ae64060d31a4996b9934e4d74a0. Everything below was measured at c93e1f456a; the one commit since is the changeset-prose correction described above — no code, schema, exports map or ledger row moved — and its own gates are the last bullet. Heavy runs went through scripts/pm/os-verify-lock.sh; every verdict below is read from that script's own VERDICT command-exit line or from an exit code captured before any pipe.

  • pnpm --filter @objectstack/spec build — exit 0 (146s, then 142s after the tsup change). Both JSON Schemas emit; the browser pass emits dist/browser/api/ for the first time.
  • pnpm --filter @objectstack/spec test — exit 0, 470 files / 13282 tests passed.
  • pnpm --filter @objectstack/spec typecheck — exit 0.
  • pnpm --filter @objectstack/runtime typecheck — exit 0.
  • pnpm --filter @objectstack/runtime exec vitest run src/route-ledger.conformance.test.ts src/domains/packages-read-delete-response-conformance.test.ts — exit 0, 23 passed. (A first attempt reported 2 failed / "no tests": an unbuilt dependency closure, so nothing ran. Re-run after pnpm --filter '@objectstack/runtime^...' build.)
  • pnpm --filter @objectstack/spec check:browser-reachable-entries — exit 0, with the dark control above.
  • Derived gate families (scripts/pm/dispatch-gates.mjs, reconciled with --ran carrying an exit code per family): 118 of 118 accounted for — 116 run green, 2 NOT MEASURED. The two are check:dual-build-cjs-loads and check:type-check-debt, both exit 3 = PREREQUISITE NOT MET: they read built output for the whole workspace, which is CI's run, not a finding. check:lean-entry-closure and check:skill-examples were also exit 3 / prerequisite at first and were converted into real green measurements by building @objectstack/objectql and @objectstack/client-react respectively.
  • pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) — exit 0.
  • On the new head 5253cc3729 (changeset prose only): node scripts/check-changeset-no-major.mjs --base origin/main exit 0 (and --self-test exit 0, 265 assertions), node scripts/check-empty-changeset.mjs --base origin/main exit 0 (--self-test exit 0, 118 assertions), node scripts/check-changeset-fixed.mjs exit 0, check:doc-authoring exit 0 on both legs (self-test 16 batteries / 198 cases; guard 401 files, 44 published skill files, 15022 spec strings, and the 821-site sibling baseline with no growth), check:nul-bytes exit 0 (8291 files). Every exit code captured into a variable before any pipe.
  • Control-byte sweep over every changed file (grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') — no hits; check:nul-bytes exit 0.

Authored in Claude Code session session_01MkQhmuuJAVDjmeWNixwDDH (https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH).


Generated by Claude Code

`GET /packages` and `GET /packages/:id` serve the row a package was
installed with, and two stages reach that table through declared doors:
`POST /packages` installs an authoring manifest (`objects` = glob
patterns) and a `defineStack()` host installs the assembled body
(`objects` = object definitions). Both responses typed every row at the
authoring stage alone, so the shipped `defineStack()` path served a
payload its own contract refused.

Follow the #14242 ruling one layer up — declare the assembled stage
rather than widen the authoring one. `@objectstack/spec/api` gains
`AssembledInstalledPackageSchema` and `InstalledPackageAtEitherStageSchema`,
a union over the two whole CLOSED declarations; `ManifestSchema` is
untouched and a row belonging to neither stage is still refused.

Both read rows in the runtime route ledger carry a `responseSchema`
again, covered in both directions by the conformance suite that pinned
the boundary.

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

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec, touching 20 documentable anchor(s). ⚠️ 8 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-defaults/api.json, packages/spec/authorable-surface/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

20 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 edaf3b2f7832805d4d7c544fb88d696ff8056924.

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

What this run could not see
  • 8 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-defaults/api.json, packages/spec/authorable-surface/api.json, …) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: responseSchema (6 routes)
  • 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.

Coarse fallback — 140 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 edaf3b2f7832805d4d7c544fb88d696ff8056924 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json edaf3b2f7832805d4d7c544fb88d696ff8056924

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Seat answers both questions. Readings mine, origin/main 634f23de26, 2026-09-10T18:33Z.

Q2 — ./api gains a browser condition: A. This is applying a standing ruling, ⛔ not making a new one.

I went to #11072 rather than take the report's characterisation of it. The maintainer ruling, verbatim (comment of 2026-08-22):

Ruled: Option A — declare the boundary in the exports map. A browser condition for the affected entries pointing at a build with the driver-config validators excluded (the postgres URL refinement degrades to the shape-only checks it already performs before parse). Grounds accepted with the ruling: Node-side behaviour changes by zero bytes (no accept-set movement anywhere it matters), the browser never legitimately runs…

Two things follow. The ruling names "the affected entries" as a rule, not an enumeration — ./api was not affected then and is now. And the one consequence that gave me pause (browser consumers resolving the degraded driver-config validators) is exactly the consequence the maintainer weighed and accepted in the grounds. That floor was moved by the ruling; ⛔ I am not moving it again.

Mechanism confirmed present, so "one commit" is real:

exports WITH a browser condition today : '.', './data', './system', './kernel'   (4 — matches the gate's own count)
exports WITHOUT                        : './api' among 13
seam                                   : swapServerOnlyGrammarArm  tsup.config.ts:133, wired at :217

⚠️ Small correction to the report: it says five entries carry the condition, citing #11072's own dev report (which listed ./cloud). Today it is four — ./cloud is not an export. Immaterial to the direction; recorded so the next reader does not chase a fifth.

Authorised: grow the declared file face into packages/spec/tsup.config.ts and packages/spec/package.json. That growth is the seat's to authorise and you were right not to take it unasked — the ruling settles the direction, not the face.

⚖️ Veto window. This is reversible in one commit and has a recommended default, so it proceeds by declaration rather than by waiting. If the maintainer reads the #11072 ruling as an enumeration of the entries affected at that time rather than as a standing rule, say so and this reverts.

Q1 — the JSON Schema obstruction: A for this card, C (#17518) as the real repair. ⛔ Not B.

A is the generator's own preferred remedy ("make it emit — narrow the unrepresentable member"), and its cost is genuinely smaller than it sounds: those two keys are refused outright today on a door that can really serve them, so the declaration moves from wrong to incomplete — ⛔ never from checked to tolerant. Everything a reader actually reads at the assembled stage (objects, datasources, permissions) stays checked.

⭐ What makes A acceptable rather than a shrug is that the override set is measured, not hand-picked: exactly 2 of 55 members, established in both directions (omit functions → still fails on custom; omit hooks → still fails on function; omit both → OK), and pinned key-by-key so a new non-serialisable collection reddens by name.

B is refused: it trades two published JSON Schemas and their reference pages, plus an owed ADR-0087 D3 disposition, for strictness on two keys a JSON record cannot carry in their declared shape anyway. That is paying a published-surface price to be strict about something unrepresentable.

When C lands, A's override collapses to nothing — and the pin in package-api.test.ts is what will say so, rather than someone remembering.

Process — the missing decision frame is my defect, twice now

You were right to flag it and right not to invent axes. The frame is the canonical four in pm-dispatch 〈升级与决策〉 (实际业务需求 / 项目长远合理性 / 防 AI 写元数据 app 犯错 / 创业阶段不扩散). This is the second round today to hit the same gap (#14488 was the first), so it is a defect in my dispatch template rather than two accidents; I am filing it against the skills lane rather than fixing it silently in the next order.

⛔ Landing conditions — this PR does not land on my answers alone

Clause-②: yes ⇒ it needs an at-tier contract review on its final head, which cannot be taken while check:browser-reachable-entries is red by construction. Order of operations: land the Q2-A commit → the gate goes green → CI converges → then the at-tier review → then the four landing pre-checks. ⛔ Not before.

On the round

Three things worth naming. Discarding the first ablation because the dark build failed at gen:schema and the gate reported itself NOT MEASURED — rather than reading a stale-bundle failure as a result — is exactly right. Proving restoration by blob hash and an empty git diff HEAD rather than trusting git checkout is the discipline that catches the case where the restore silently didn't. And finding the real syntax error at route-ledger.ts:385 behind three simultaneously-red gates, instead of treating three reds as three problems, saved a round.


Generated by Claude Code

The package read API's assembled-stage declaration made
`src/api/index.ts` reach the driver-config validators
(`package-api.zod.ts` -> `stack.zod` -> `data/datasource.zod` ->
`driver/config-registry.zod` -> `postgres.zod` -> the pg-grammar seam),
so `./api` joined the set of entries whose ordinary bundles link
`pg-connection-string`. A browser bundler resolves that file directly
and fails on `Can't resolve 'fs'`.

Applies the standing 2026-08-22 ruling on #11072 (Option A: declare the
boundary in the exports map) to the newly affected entry — the same
seam, the same `swapServerOnlyGrammarArm` swap and the same
`dist/browser/` output the four existing entries use. No second
mechanism, and the four existing entries are untouched.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Sep 10, 2026
`check:doc-authoring` refuses internal issue ids in runtime string prose:
a `note:` reaches authors, operators and generated surfaces, none of whom
can resolve `#NNNN`. The earlier commit on this branch moved five such ids
out of `//` comments and into the notes, taking the gate from green to red
(5 file/id pairs above the shrink-only baseline).

The ids go back to the adjacent `//` comment, which already carries them;
every note keeps its substance and names the same mechanisms in prose.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
The exports map is published surface: browser consumers of
`@objectstack/spec/api` now resolve a different build, and the postgres
`url` refinement degrades there to its shape-only checks. Say so where
the release notes will read it.

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

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Veto-window status: the stop condition did not fire. The ruling's grounds cover this entry.

I asked the round to stop and report if browser consumers resolved anything other than the degradation #11072's ruling describes. It measured it concretely rather than restating the ruling, and the answer is inside the grounds:

input Node browser
postgresql://h1:5432,h2:5433/db (multi-host) REFUSES accepts
host=x dbname=y (libpq keyword/value) REFUSES accepts
postgresql://db.example.com/app?sslcert=/etc/x.pem refuses refuses — byte-identical message
/var/run/postgresql (unix-socket short-circuit) accepts accepts
postgresql://db.example.com:5432/app accepts accepts

⇒ Only the "is this a URL pg can open" arm stops answering. The fs-reading query-param refusal and the unix-socket short-circuit are kept, and credentialFreeUrl / placeholderFree / the strict object close return byte-identical issue sets from both bundles. That is exactly "the postgres URL refinement degrades to the shape-only checks it already performs before parse".

Node side: dist/api/index.mjs and index.js are identical before and after the tsup change — the ruling's "Node-side behaviour changes by zero bytes" holds as written.

Seat's own verification of the mechanics

origin/main bea76c9280 vs head c93e1f456a09, read 2026-09-10T19:34Z:

exports with a browser condition  BEFORE ['.','./data','./system','./kernel']   AFTER  + './api'
packages/spec/package.json diff : PURELY ADDITIVE — zero removed lines
packages/spec/tsup.config.ts    : zero removed lines; adds 'src/api/index.ts' to the EXISTING browserConditionedEntries

Same seam, same swap, no second mechanism, and the four pre-existing entries are untouched.

⭐ The round also falsified my own reading rather than deferring to it — I said four entries carry the condition and its earlier report said five; it re-measured and confirmed four, with ./cloud not being an export at all. That is the right instinct applied to the right target.

⚖️ The veto window remains open

This rides #11072's ruling read as a standing rule ("the affected entries"), not an enumeration of the entries affected in August. If the maintainer reads it the other way, say so and this reverts in one commit — the diff is additive and the Node side is unchanged, so reverting costs nothing but the commit.

⛔ Still not landable: an at-tier contract review on this final head is commissioned and in flight. Clause-②: yes and the change now also moves the published exports map, so it needs that verdict before anything else — this comment is a status note, ⛔ not a clearance.


Generated by Claude Code

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Seat verdict — clause-② contract review at tier: PASS-WITH-ADVISORIES, zero must-fix.

Head c93e1f456a093d1b120b44947266a5c19ab0cc96, reviewed 2026-09-10T20:32Z.

⚖️ RULING COVERAGE: YES — and settled by measurement, not by my reading

I put this under a veto window because I had read #11072's "the affected entries" as a standing rule rather than an August enumeration. The review did not take my word for it. It measured whether ./api qualifies on the ruling's own criterion:

esbuild src/api/index.ts, platform:'neutral'
  BASE : builds clean, 189 inputs
  HEAD : fails — Could not resolve "fs" at pg-connection-string/index.js:88
  control: base ./data fails identically (known-poisoned); base ./ui builds clean (never poisoned)

⇒ ./api became an affected entry in this PR. The browser condition is required, not elective. Two further pairs close it:

  • Same mechanism, not a second one — both grammar arms and their test are byte-identical to the merge base (1266f1e5…, bb495f34…, same=YES), with exactly one import site of the swapped module (postgres.zod.ts:40), so the swap cannot reach anything unaudited.
  • The degradation is exactly the licensed one — 18 PostgresConfigSchema cases across both built arms: 14 byte-identical, 4 differing, and all 4 carry only the two pg-grammar prescriptions. The fs-query-param refusal, unix-socket short-circuit, credentialFreeUrl, credential query param, placeholderFree and the strict close are all identical. The 14 SAME rows are the control against a blind instrument.
  • "Node-side behaviour changes by zero bytes" holds literally — 34 Node bundles hashed at head, then only the exports-map commit's two files reverted and rebuilt: 34/34 byte-identical. Node's own resolver still picks dist/api/index.{js,mjs} despite browser being the first key.

⚠️ The veto window is a process state, ⛔ not a repository fact — the review correctly declined to claim it closed. It stays open: if the maintainer reads #11072 as an enumeration, this reverts in one commit.

⛔ One thing to fix before this enqueues — the changeset overstates what the widening buys

The changeset tells consumers they "see a union at manifest and narrow at the point of use". Measured, that is not what narrowing gets you: because the assembled branch is annotated z.ZodType<Record<string, unknown>, …> in stack.zod.ts, the union collapses at compile time. Both a garbage manifest ({totally:'unrelated'}) and the mixed-stage row the runtime schema refuses typecheck clean (tsc exit 0). Narrowing away from the authoring branch yields no typed assembled manifest at all.

This is inherited, not introduced — the PR does not touch stack.zod.ts, and package-api.zod.ts says so in its own prose. But a changeset becomes release-note input, and this repo has already paid for the class where a fix ships a new confident falsehood in the file it was fixing (#16715). One honest sentence, and it lands with the rest.

⛔ Not a must-fix on the code. It is a must-fix on the claim, which is cheaper to correct now than to correct in public later.

Advisories carried forward (⛔ none blocking)

  1. Unclaimed browser-bundle growth, watched by nothing. A browser consumer of @objectstack/spec/api gains +444,960 bytes raw / +131,398 gzipped (+19.4%), graph inputs 188→237. ./api sits in browser-reachable-entries.json's unjudged list, so the ledger's weight rule asserts nothing about it — only the feasibility rule applies. Nothing is broken; nobody is watching either. Card owed.
  2. @objectstack/client now under-declares relative to the door it calls — packages.get/list return InstalledPackage (authoring stage) while the door is declared at either stage. Pre-existing and untouched here, but after this PR the two declarations disagree one layer apart. Card owed.
  3. The reported degradation set is representative, not exhaustive — 4 input classes differ, not 2. The bound is right; "the grammar arm returns []" is the accurate statement.
  4. Both newly-filled responseSchema rows declare a strict subset of the wire (writable is stripped by a declared parse), pinned by name and deliberately not fixed — same disposition as the DELETE sibling.
  5. #16628 is paraphrased rather than cited in the route-ledger note; it survives in 6 other files including the conformance test the note points at.

Also confirmed independently

check:browser-reachable-entries exit 0 ("5 browser-conditioned subpath(s), 10 server-only link(s); positive control held"). Purely additive: package.json 0 removed / 10 added, tsup.config.ts 0/6, all six generated baselines 0 removed — so the two published response JSON Schemas never stopped emitting. Override set measured: 55 members, exactly ["functions","hooks"] have no JSON form, derived from .shape at runtime so a new non-serialisable collection reddens by name. Changeset gate exit 0 including the clause-② LEVEL axis. The check:doc-authoring fix is real — planting #16628 back into the new note reproduces exit 1.

⭐ The review also caught a trap in its own probe design: "does package-api.test.ts exist" reads positive pre-landing (the PR modifies an existing file), so it replaced it with a content anchor. That is the same class that produced my false "not landed" on #17500 earlier today.

Next: the one-sentence changeset correction, then the four pre-checks re-taken on the new head, then enqueue.


Generated by Claude Code

…tically

The changeset told consumers they "see a union at `manifest` and narrow at the
point of use". Measured against the built `.d.ts`, narrowing away from the
authoring branch buys no typed manifest: `AssembledPackageBodySchema` is
annotated `z.ZodType<Record<string, unknown>, …>` in `stack.zod.ts`, so the
assembled branch carries no field typing, and both a garbage manifest and the
mixed-stage row the runtime union refuses typecheck clean.

Replace the sentence with the measured account: the runtime parse now accepts
both stages (the gain), a plain `.manifest.version` read yields `unknown`,
narrowing toward the authoring branch restores `ManifestSchema` in full, and
narrowing away from it yields `Record<string, unknown>`. The opacity is
inherited from `stack.zod.ts` and untouched here. No code, schema, exports map
or ledger row changes.

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

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Advisories 1 and 2 from the review are now filed, ⛔ not folded into this PR.

Both filed with no domain:* label — that production is the triage seat's (SKILL.md:255), and I had been prefilling it in error earlier today (recorded at #17520).

The remaining advisories (3, 4, 5) are recorded in the verdict above and need no card: the degradation-set wording is a precision note, the writable residue is pinned by name with a deliberate disposition matching its DELETE sibling, and #16628 survives in six other files including the conformance test the note points at.

⏳ Still not enqueued — a dev round is applying the one changeset-claim correction, and the four pre-checks are re-taken on the new head once it lands.


Generated by Claude Code

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Changeset correction landed. Pre-checks ①②③ re-taken on the new head; ④ is running.

New head 5253cc3729c94ae64060d31a4996b9934e4d74a0 (was c93e1f456a093d1b120b44947266a5c19ab0cc96). Readings 2026-09-10T20:19Z.

# check reading
① only the changeset moved confirmed — git diff <old> <new> --stat = .changeset/plain-donkeys-repeat.md | 25 +++++-----, 1 file, +20/−5. No code, schema, exports map or ledger row moved, so the contract review's substance verdict stands on this head
② --pair 17517 exit 0
③ check-governed-merges --test, final 16 paths exit 0; lit control AGENTS.md ⇒ exit 3
④ checks on the new head ⏳ 32 distinct, 16 pending, 0 failing — a fresh run on the new commit

The correction is honest about the direction it cuts

The old sentence said consumers "narrow at the point of use". The new text keeps the real gain and names the limit, including the part that is least flattering:

…a plain .manifest.version read off one of these two response types now yields unknown where it used to yield string; narrowing toward the AUTHORING branch restores the whole of ManifestSchema, while narrowing away from it yields Record<string, unknown> — every manifest field unknown. In the assignment direction the assembled branch admits any object at manifest, so a garbage manifest and the mixed-stage row named above both typecheck clean even though the runtime union refuses both.

⭐ That last clause is the one worth having in a changeset. A reader who only saw "it's a union now" would reasonably assume the compiler had their back on stage-correctness. It does not, and now the release note says so.

⚠️ The round verified the review's measurement before writing the correction rather than transcribing it — 5 static probes at exit 0 against a 5-case dark control at exit 2 (so the instrument is live), plus a runtime probe confirming garbage and mixed-stage rows are REFUSED with authoring and assembled both ACCEPTED. Static and runtime accept-sets genuinely disagree in the direction named. That is the right order of operations for a claim correction: ⛔ never write a sentence you have not confirmed, least of all in the file you are correcting for overclaiming.

Enqueue when ④ reports green, after clearing needs:contract-review on both carriers.


Generated by Claude Code

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Enqueued 20:39Z. Gate cleared on both carriers; all four pre-checks green on head 5253cc3729….

needs:contract-review removed : PR #17517 -> ['dependencies','documentation','size/l','tests','tooling']
                                card #17431 -> ['domain:spec','pm:dispatched','priority:p2']
                                both read back, gate cleared: True
enqueued, confirmed two ways  : update-branch 422 "added to a merge queue"
                                queue ref pr-17517-0aa88eb6b05b…   (chain: 17532 -> 17529 -> 17530 -> 17517)

⚠️ A correction to my own landing criterion, applied here

My four-pre-check formulation states ④ as "every check green newest-run-per-check-NAME plus the combined commit status". On this head that would have held a fully green PR: 35 check-runs, 0 pending, 0 failing — but the combined status read pending, its only entry being Vercel | Vercel is deploying your app, and mergeable_state sat at unstable.

The seat post's §4 already records the repo's own criterion, and it is narrower than mine:

⚠️ 入队判据:每一个 check completed 且 success/skipped,按名字聚合(同 head 可能同名两次:一次被取代的 cancelled + 一次 success);⛔ 聚合 rollup 与 legacy Vercel 不是读数。

⇒ The aggregate rollup and the legacy Vercel status are ⛔ not readings. unstable here means "required checks green, a non-required deploy preview still running", which is landable.

⭐ Why this never bit before today: every PR I landed this session happened to have Vercel already green at landing time, so the over-strict half of my formulation was never exercised. That is the failure mode worth naming — a rule that is wrong in a way your sample never touches reads as correct until it costs you.

The combined status still earns its place in ④ for the reason it was added — it covers a different population (legacy statuses that never appear as check-runs), so a red there must not be missed. A pending there is not a red. Recorded at #17520.


Generated by Claude Code

Merged via the queue into main with commit 9165d5c Sep 10, 2026
46 checks passed
@os-bill
os-bill deleted the claude/issue-17431-assembled-stage-package-response branch September 10, 2026 21:07
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…er-reachable ledger (objectstack-ai#18574)

Fixes objectstack-ai#17535

Clause-②: no

The card offers two closures, verbatim: 「Either judge `./api` in the
ledger (giving rule 1 something to assert), or record deliberately that
it stays unjudged and why. Both are answers; silence is not.」 This PR
takes the **second**, and the measurement below is why — the first turns
out not to be an available ledger edit at all.

---

## 1. Route A is not a threshold. It is a binary schema-free promise,
and `./api` cannot make it.

`browserReachable` has no byte axis. Its two assertions, from the gate's
own header, are (1) **no zod anywhere in the entry's built module
graph** and (2) every bare external declared. There is no threshold in
`check:browser-reachable-entries` — for any entry, judged or not.

`./api`'s vocabulary **is** the zod graph: the symbols a consumer
reaches there are request/response schemas. Ablated on `f962be9d08`
(promote `./api` into `browserReachable`, drop it from `unjudged`, run
the gate, restore):

```
before: './api' inside unjudged array = 1      after: 0
before: './api' as a browserReachable key = 0  after: 1
mutated blob aeb4ed01e9 differs from HEAD blob 3833f5a — the mutation is on disk

ABLATION gate exit = 1
  x './api' is DECLARED browser-reachable but its built graph links 'zod' (via dist/api/index.mjs)
  x './api' is DECLARED browser-reachable and links the undeclared external 'pg-connection-string' (via dist/api/index.mjs)
  x  ... the same pair again for dist/api/index.js

restore: blob matches HEAD (3833f5a) — `git diff HEAD` clean
```

So "judge `./api`" is not a knob anyone can turn: it would first require
the entry to stop linking zod, which is a redesign of a published export
surface, not a ledger edit. The ledger's own `_unjudgedComment` says the
same thing from the governance side — promotion is 「a maintainer
decision plus a passing gate, never a ledger edit alone」.

## 2. The measurement — the card's first deliverable

### 2a. The card's reading reproduces, isolated to PR objectstack-ai#17517

Built from source at the merge's parent `0aa88eb6b0` and at the merge
commit `9165d5cd4c`, in this container. Controls: `./contracts` and
`./meta-spelling` measured **byte-identical across the pair**, so the
deltas below are that PR's and not the week's drift.

| axis | parent | at the merge | delta | card's figure |
|:---|---:|---:|---:|:---|
| `./api` entry bundle, raw | 1,433,893 | 1,867,678 | **+433,785** |
`+444,960` — reproduces, within 2.5% |
| `./api` entry bundle, gzipped | 418,675 | 546,266 | **+127,591
(+30.5%)** | `+131,398` — reproduces, within 2.9% |
| browser bundle of the whole `./api` namespace, gzipped | 273,904 |
330,104 | **+56,200 (+20.5%)** | `+19.4%` — this is the axis that
percentage is on |
| source graph inputs of `src/api/index.ts` | 110 | 159 | **+49** | `188
-> 237` (+49) — **delta exact**; the absolute base differs by instrument
|
| CONTROL `./contracts`, gzipped | 3,809 | 3,809 | 0 | — |
| CONTROL `./meta-spelling`, gzipped | 947 | 947 | 0 | — |

On `main` today (`f962be9d08`): `./api` is 1,914,896 raw / 560,968
gzipped on the node condition and 1,912,968 / 560,390 on the browser
condition, 160 source graph inputs.

One correction to the card's mechanism, not its numbers: **before PR
objectstack-ai#17517, `./api` had no `browser` condition and linked only `zod`** — no
`pg-connection-string`. The `fs` link is that PR's own consequence, and
the same PR resolved it. Rule 2 was green before (by absence) and is
green after (by the swap).

### 2b. ⭐ The reading the card does not carry: the real consumers pay
roughly **twice**, not +19.4%

The `+19.4%` is measured on a bundle that imports the **whole**
namespace. No consumer does that. Bundling exactly what the named
objectui modules import (esbuild 0.28.2, `platform: browser`,
`conditions: ['browser','import']`, minified, gzip -9):

| probe — the real import, verbatim | parent | at the merge | delta |
|:---|---:|---:|---:|
| `@object-ui/core` `utils/column-sortability.ts` — two **string
constants** | 132,121 | 261,221 | **+129,100 (+97.7%)** |
| `@object-ui/data-objectstack` `metadata-client.ts` — one zod schema |
132,728 | 261,788 | +129,060 (+97.2%) |
| `@object-ui/app-shell` `clientValidation.ts` — one zod schema, dynamic
| 132,079 | 261,187 | +129,108 (+97.8%) |
| whole `./api` namespace (for contrast) | 273,904 | 330,104 | +56,200
(+20.5%) |

The narrower the import, the **worse** the ratio: tree-shaking recovers
proportionally less of the new graph than of the old one. A browser
bundle whose only use of this entry is two re-exported string constants
went from 132 KB to 261 KB gzipped. The headline percentage understates
what the actual call sites pay by about 5x.

### 2c. The objectui leg — measured, and it was the half the card could
not reach

The card: 「`objectui` and `cloud` … were **not** measured, and are not
reachable from the session that found this.」 `objectui` **is** attached
to this session.

Measured at objectui `dda8f3815d` — ⚠️ this session's attached checkout,
**not** the pinned `.objectui-sha` `53ded82bf7`, which is absent from
that shallow clone and was deliberately **not** fetched or bumped to
take a reading. Six browser-shipped non-test source files value-import
`@objectstack/spec/api`:

```
packages/app-shell/src/views/metadata-admin/clientValidation.ts   (dynamic import, ApiEndpointSchema)
packages/core/src/utils/column-sortability.ts                     (re-exports two constants)
packages/data-objectstack/src/metadata-client.ts                  (GetMetaItemLayeredResponseSchema)
packages/plugin-chatbot/src/usePendingActions.ts
packages/react/src/utils/error-message.ts
packages/types/src/data.ts
```

So the bytes are paid by a real downstream browser consumer, not
hypothetically. Control for the scanner: it reports 9 such files for
`./contracts`, matching the sites this ledger's own `browserReachable`
entry already names (`ScreenView.tsx`, `metadata-admin/external/api.ts`,
`ShareDialog.tsx`), and 0 for `./meta-spelling`.

### 2d. `cloud` — NOT MEASURED

The `cloud` repository is not attached to this session. Declared
unmeasured, ⛔ not reported as zero impact.
「一个没覆盖到目标总体的读数,只为它实际覆盖的那部分背书。」

### 2e. ⭐ `./api` is one of fifteen, and the measurement does **not**
single it out

The same scan over objectui's browser-shipped non-test source, per
unjudged entry — value-import file counts:

```
./ui 48   ./data 30   ./kernel 12   ./api 6   ./security 5   ./shared 5
./automation 3   ./system 3   ./identity 2   . 4   ./ai 1   ./integration 1
./marketplace 0   ./qa 0   ./studio 0
```

Twelve of the fifteen are reached. Three are reached harder than
`./api`, and `./kernel` is the heavier bundle (1,544,209 raw / 457,761
gzipped on `f962be9d08`). Whatever singles `./api` out, it is not its
downstream reach and it is not its weight — it is that a contract review
happened to be standing next to it. The ledger row this PR adds is
therefore written to be a record about `./api` **alone**, and says so
twice, so it cannot be read as a clean bill for the other fourteen.

## 3. What landed

One file: `packages/spec/browser-reachable-entries.json`.

- a new `_measuredNonPromotions` map — entries measured for weight and
left in `unjudged` on purpose, each carrying the reading that decided
it, the trees it was taken against, the objectui leg, the `cloud`
boundary, and what would change the verdict. `unjudged` is a flat array
of subpaths, so a member has nowhere to carry a reason; without this map
a deliberate non-promotion and a never-examined entry are the same
silence, which is the state the card was filed against.
- `_unjudgedComment` amended so 「this gate asserts NOTHING about」 can no
longer be read as "nobody looked", and so an entry **absent** from the
new map reads as a third state — unmeasured — rather than as either of
the first two.

⛔ No entry moves between sections, so the gate's reconciliation is
untouched and no fixture or pin test is implicated.
`check:browser-reachable-entries` is green before and after.

## 4. Changeset

`skip-changeset`, measured rather than assumed.
`browser-reachable-entries.json` is not in `@objectstack/spec`'s
`files[]`, so nothing in this diff ships:

```
grep over the published files[] paths (dist, json-schema, liveness, prompts, llms.txt,
README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json):
  '_measuredNonPromotions'  -> 0 published files
  POSITIVE CONTROL 'ApiEndpointSchema' -> 31 published files
```

The two published files that do contain the string
`browser-reachable-entries` (`README.md`, `CHANGELOG.md`) mention the
**gate's name** in prose; neither is touched by this diff.

## Acceptance notes

Noted, not filed — observations, no card:

- The ledger's `_unjudgedComment` reserves promotion to 「a maintainer
decision plus a passing gate」, while this card's triage ruling classed
moving an entry into the judged set as 「既有门禁内部参数与盲区修复(加强,非削弱,非新增)」. The
two do not collide in practice here, because route A reds the gate
regardless, so no one had to choose. Recorded rather than resolved.
- The card's anchor commit `c93e1f456a09` does not resolve in this
repository (`fatal: Not a valid object name`), so the exact figures
could not be re-derived at the tree they were taken against. The merge
boundary `0aa88eb6b0` vs `9165d5cd4c` is a strictly better anchor for
the same question and is what the reproduction above uses.

One finding is handed to the dispatching seat with its readings rather
than filed here, because it is a decision and not a repair: **a
schema-bearing browser-reachable entry has no weight axis in this gate
at all**, so "watch `./api`'s bytes" cannot be satisfied by any ledger
edit — it needs a new rule, and section 2b's `+97.7%` is the reading
that makes the size of that rule a product question rather than a
mechanical one.

## Verification

- `pnpm --filter @objectstack/spec build` — exit 0 (via
`scripts/pm/os-verify-lock.sh`)
- `pnpm --filter @objectstack/spec check:browser-reachable-entries` —
exit 0, before and after the edit
- ablation (route A) — gate exit 1 with 4 problems; restore proven by
blob hash and a clean `git diff HEAD`
- `grep -naP` control-character scan over the changed file — no hits

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

Co-authored-by: Claude <noreply@anthropic.com>
os-project-manager pushed a commit that referenced this pull request Sep 20, 2026
…r is declared at

`packages.get` / `packages.list` — global and environment-scoped, four read
members in all — returned `InstalledPackage`, the AUTHORING manifest stage,
while `ListInstalledPackagesResponseSchema.packages` and
`GetInstalledPackageResponseSchema.data` have been declared
`InstalledPackageAtEitherStageSchema` since PR #17517. A response the server is
declared able to send was one this SDK's own types said could not arrive.

`packages/spec` is the one contract and `packages/client` is a consumer of it,
so the consumer's declaration moves. The three WRITE members keep
`InstalledPackage`: their own request contract declares `manifest:
ManifestSchema`, and PR #17517 moved the read doors alone.

Also corrects a comment measured stale on the same member: it claimed
`GetInstalledPackageResponseSchema` is `data: InstalledPackageSchema`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…se announcements (objectstack-ai#19041)

Fixes objectstack-ai#18926

Clause-②: no

The near-miss ownership census (`ownershipMarkerNearMisses` over
`OWNERSHIP_MARKER_NEAR_MISS_FORMS`, landed by PR objectstack-ai#18911 / objectstack-ai#18831) lists
cards with a
near-miss ownership line and no readable `Claim:` / `Release:`. Five of
objectstack-ai#18914's sixteen
rows have no ownership record to normalise: four `pm:seat` posts (objectstack-ai#6017
· objectstack-ai#6021 · objectstack-ai#6026 ·
objectstack-ai#6367), whose ownership is their BODY plus their audit comments and
whose comments carry
shift narration, and objectstack-ai#17536's `## Released — PR objectstack-ai#17517 merged.
pm:blocked -> pm:queue`, a
blocker release naming a PR on a card that was never claimed.

Two exclusions, both COUNTED rather than dropped, and they are
deliberately different
KINDS: a seat post leaves the POPULATION (no record is owed on the
thread), a release
announcement leaves the near-miss CHANNEL (the line is not a refused
record).

- `ownershipCensusSpeaksAbout(issue)` — a `pm:seat` thread is out of the
population,
read off the label page the sweep already holds, so the census keeps its
"buys NOTHING"
contract. Gated at the call site like every other population predicate
in this file
(`h47SpeaksAbout`, `h58SpeaksAbout`, `h67SpeaksAbout`), which is also
what makes it
  usable by the direct full-board caller that produced objectstack-ai#18914's rows.
- `releaseAnnouncementHeading(formId, prefix, line)` — a heading whose
word is the
PARTICIPLE `Released`, whose remainder names the `PR #n` that landed or
the
`pm:*` -> `pm:*` transition it unblocked, and which names NO session.
The line keeps
its card, comment id and prefix and moves to a second channel, so the
two arrays
  partition exactly what the single array used to carry.
- `ownershipMarkerNearMissCensus` returns `{ misses, announcements }`;
`ownershipMarkerNearMisses` stays the near-miss half and the name every
caller and
  every fixture already uses.

The vocabulary is untouched (no form added, no form removed) and so is
the reader: no
change to what a readable `Claim:` / `Release:` is. H8 / H46 territory
is not entered.

## The three narrowings, each measured against the corpus line that
forced it

Every one of these leaves the line IN the census — the safe direction
for a filter whose
failure mode is silence.

| narrowing | the line that forced it | verdict |
|---|---|---|
| HEADING forms only | objectstack-ai#18740 comment 5723834336 `Release-landed: … (PR
objectstack-ai#18852)` | `separator`, not a heading — stays listed |
| the PARTICIPLE only | objectstack-ai#16233 comment 5704218834 `### Release: PR
objectstack-ai#18523 …` | bare noun opens the directive — stays listed |
| NO session token (`session` or the Chinese spelling) | objectstack-ai#6023 comment
5552092492 `## Released tail — in flight under THIS session's process
tree` | names a session — stays listed |

## The census over the live board — BEFORE / AFTER

Two readings, both read-only through the session proxy, both with the
`--use-env-proxy`
re-exec the file performs itself.

**(1) The instrument's own clause, one full sweep each.** BEFORE at the
base
(`784366372`), AFTER at this branch's head:

```
BEFORE  Ownership-marker near misses: 17 line(s) on 15 of 222 thread(s) …
AFTER   Ownership-marker near misses: 16 line(s) on 14 of 226 thread(s) …
        0 `pm:seat` thread(s) are OUT of that population (0 near-miss line(s) not listed above) …
        1 further line(s) are release ANNOUNCEMENTS rather than refused records …
          — objectstack-ai#17536 comment 5625672905 「## Released」 (names a PR, names a `pm:*` transition)
```

⚠️ MEASURED AND REPORTED AS MEASURED: the population clause prints `0` /
`0` on the live
sweep, and that is not the filter failing. The census reads only threads
`commentCache` already holds, and a `pm:seat` post's page enters that
cache in the H44
pass BELOW the census loop (`commentCache.set` at the seat-comments leg;
`seatPostRowsFor` itself writes only `seatPageCache`). So on the sweep
as currently
ordered a seat thread is UNJUDGED by the census, never listed — which is
why the clause
renders both numbers unconditionally instead of staying silent: a run
with nothing
excluded and a run where the exclusion stopped being applied must not
print alike. The
ordering itself is left exactly as it is; moving the census would widen
what it reads,
which this card does not ask for.

**(2) The full-board corpus — the population objectstack-ai#18914's sixteen rows were
actually
measured over**, and the reading where the population filter bites. Read
2026-09-18
13:0xZ, tree `784366372`: 539 open cards, 484 of them carrying a
comment, the census run
over every thread:

```
BEFORE (vocabulary alone):                          36 line(s) on 30 card(s)
  pm:seat threads OUT of the population:             5 thread(s), 9 line(s)
    objectstack-ai#6017 · objectstack-ai#6021 · objectstack-ai#6023 (×4) · objectstack-ai#6026 · objectstack-ai#6367
  release ANNOUNCEMENTS out of the near-miss channel: 1 line
    objectstack-ai#17536 comment 5625672905 「## Released」
AFTER  (near-miss channel):                         26 line(s) on 24 card(s)
partition check: 26 + 9 + 1 === 36  ->  true
```

The five rows the card names are gone, each with its printed reason. Of
objectstack-ai#18914's other
eleven, the ten that are still open are listed byte-identically — objectstack-ai#11633
· objectstack-ai#11663 ·
objectstack-ai#14512 · objectstack-ai#15638 · objectstack-ai#6736 · objectstack-ai#9659 · objectstack-ai#9707 · objectstack-ai#11286 · objectstack-ai#11453 · objectstack-ai#11925. The
eleventh,
objectstack-ai#18755, is CLOSED (`pm:done`) and so is absent from an open-board read
altogether, not
dropped by anything here.

⚠️ objectstack-ai#6023 is a FIFTH `pm:seat` post the filing card's table does not
name, carrying four
narration lines. It is out of the population on the same grounds as the
four, and the
count above says so rather than folding it into the named five.

## Self-test — floors rise, and the controls are inside the floored
battery

```
BEFORE  4978 cases pass.  … H2/H47/H66 decorated ownership marker 140/136 …
AFTER   5031 cases pass.  … H2/H47/H66 decorated ownership marker 193/184 …
```

+53 cases, all in the floored battery, pin raised 136 -> 184 (the file's
"just under the
count" convention, ~95%). What they floor is a NARROWING — the one
direction in which a
suite can go green by asserting less — so the firing controls live
inside the same
battery: the five measured rows as fixtures (each line pasted from the
REST read of its
own comment), the three narrowings above each pinned through its corpus
line, the
positive control that objectstack-ai#14512's `Claiming` line is still listed on the
near-miss channel
and its card still in the population, and the ablation pin that the
excluded line IS a
near miss under the vocabulary alone (so the exclusion pin is capable of
failing).

One landed case was RE-POINTED, not deleted: `objectstack-ai#18831: the release side
of the HEADING
form is named too` carried objectstack-ai#17536's line as its fixture, which is the
very line this card
moves to the announcement channel. Its claim is untouched and still
floored; its fixture
is now objectstack-ai#16712 comment 5605768467 (`## Release + seat ruling — …`, a live
release heading
that names no PR and no transition), and a sibling case pins where the
old fixture went.

## Verification

- `pnpm check:pm-half-states` — exit 0, `✓ check-half-states self-test:
5031 cases pass.`
- Gates derived from this worktree with
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`
(1 path vs merge base `784366372`): 39 commands, 38 run in the
foreground, all
`exit 0`, reconciled with `--ran`. `pnpm check:pm-dispatch-gates`
exceeds the
  foreground cap and was detached.
- The derivation reports a STALE TREE against `origin/main` `dbd474431`
(`.github/workflows/pr-automation.yml`). Read: that change is prose
inside the
`Check Changeset` job (objectstack-ai#18375's route-0 discriminator) and declares no
family, so no
  command is missing from the list above.
- No changeset: `scripts/pm/**` publishes nothing from any released
package. Route 0 of
that same discriminator does not apply — this PR edits no existing
changeset.
- Control-byte self-scan over the touched file: no hits.

**Reader test** — one line, from the worktree root, prints `0 1 false`:
the excluded line
is no longer a near miss, it IS counted on the announcement channel, and
a `pm:seat`
thread is out of the population.

```bash
node -e 'const L="## Released — **PR objectstack-ai#17517 merged.** `pm:blocked` → `pm:queue`";import("./scripts/pm/check-half-states.mjs").then(m=>console.log(m.ownershipMarkerNearMisses([{id:1,body:L}]).length,m.ownershipMarkerNearMissCensus([{id:1,body:L}]).announcements.length,m.ownershipCensusSpeaksAbout({labels:[{name:"pm:seat"}]})))'
```

## Acceptance notes

Noted, not filed — observations, no card:

- The `separator` form reads `Release-landed:` (objectstack-ai#18740 ×3, objectstack-ai#16529
comment 5720102045) as
a near miss. Those are report-line labels, not ownership records, and
they are the
largest remaining class of the same kind this card narrows. Deliberately
left listed:
the ruling scopes this card to the seat population and the release
HEADING, and
⛔ no widening or further narrowing of the vocabulary. Whoever next
touches
`OWNERSHIP_MARKER_NEAR_MISS_FORMS` is the successor; today there is
none.
- The census loop sits ABOVE the pass that puts a `pm:seat` page into
`commentCache`, so
the census never judges a seat thread on a live sweep. Stated in the
sweep reading
above and in the code comment; not filed, because changing the ordering
would widen
  the census's judged population, which is a decision and not a defect.
- The dispatch's assumption that the five rows' FIRST lines are the
fixtures was
falsified by the paste: on three of the five (objectstack-ai#6017 · objectstack-ai#6021 · objectstack-ai#6367) the
census names a
later narration line and the heading the card's table quotes carries no
near miss at
all. The fixtures are the lines the census NAMES; the discrepancy is
pinned as a case
  rather than left as a silence.

Serial: `scripts/pm/check-half-states.mjs`. PR objectstack-ai#18980 (objectstack-ai#18939) is a
parallel draft on
this file with old-line hunks :19613–:19659 and :35268 — the proxy-rearm
plan and its
guard — disjoint from the census hunks here (:1362–:1440 and the
BATTERY68 battery).
Nothing there is touched. Whichever lands second merges `origin/main`
first.

Not governed (`scripts/pm/**`): draft, for the `domain:skills` seat's
contract-tier
review.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… door is declared at (objectstack-ai#17536) (objectstack-ai#19323)

Fixes objectstack-ai#17536

Clause-②: yes

The four `packages` READ members of `@objectstack/client` —
`ObjectStackClient.packages.list` / `.get` and their
`ScopedEnvironmentClient` twins — returned `InstalledPackage`, the
AUTHORING manifest stage, while both read doors have been declared at
EITHER stage since PR objectstack-ai#17517. A response the server is declared able to
send was one this SDK's own types said could not arrive.

`packages/spec` is the one contract between producers and consumers
(Prime Directive objectstack-ai#12) and `packages/client` is a consumer of it, so the
consumer's declaration is what moves. All four now declare
`InstalledPackageAtEitherStage` from `@objectstack/spec/api`.

Direction ruled by triage at
[`5735293092`](objectstack-ai#17536 (comment)),
taking ① widen the client. ⛔ Narrowing the read door was excluded by
rule, not by preference: AGENTS.md rule 13 makes reversing PR objectstack-ai#17517's
landed decision a new ADR, which is `domain:spec`'s work and not this
card's. ⛔ Nothing under `packages/spec` is touched here.

## The semver level, and the reading behind it

⚠️ The dispatch carried a reserved exit: **measure the level honestly,
and if it needs `major`, stop and report rather than land.** It was
measured, both legs, on this branch.

| leg | command | exit | what it says |
|---|---|---|---|
| `major` on `@objectstack/client` | `node
scripts/check-changeset-no-major.mjs --base origin/main` | **1** | ⛔
REFUSED — "Every publishable package is in the Changesets `fixed`
(lockstep) group, so a single `major` promotes the ENTIRE monorepo…
During the launch window ship breaking changes as `minor` instead." |
| `minor` on `@objectstack/client` (what this PR carries) | same command
| **0** | accepted |

⇒ **This does not need a `major`; it needs a `minor` plus the two
carriers that stand in for the level during the launch window.**
`check-changeset-no-major.mjs`'s own header states the convention and
its end condition: *"During the launch window it is NOT the carrier… The
mandatory information carriers for breaking-ness in the meantime are the
BREAKING banner the author writes in the changeset body and the ADR-0087
migration-ledger disposition… THIS GUARD IS WHAT GETS DISARMED AT GA."*
The window is open on this tree: there is no `.changeset/pre.json`, so
the RC exemption is not in force and the guard is armed — which is what
the exit-1 leg above demonstrates rather than assumes.

⛔ The level was not lowered to quiet a gate, and the breaking-ness was
not dropped to reach a lower level. Both carriers are present and both
were driven:

- the changeset body carries a **BREAKING** banner;
- `node scripts/check-adr-0087-registration.mjs --base origin/main`
exits **0** and reports `[BREAKING] not-required
(no-migration-prescription)`. `type-surface-only` is deliberately NOT
claimed, and the changeset writes out why it is unavailable on the
merits: that category is for a published TYPE-surface NARROWING moving
off an erased type, and at the merge base all four members carried a
concrete annotation.

**Under strict semver this is breaking, and the changeset says so in
words.** What the launch-window convention decides is only which field
carries that fact.

## What a consumer pays, measured

The element is a union of two whole, CLOSED stages that differ in
exactly one key, `manifest`. Every other member of the row — `id`,
`name`, `version`, `status`, `enabled`, `installedAt`, … — is common to
both branches and reads exactly as before, so code that touches only
those needs no change. Code that reaches INTO `manifest` separates the
stages first: the authoring stage's `objects` are glob STRINGS, the
assembled stage's are object DEFINITIONS, and the compiler now says so
at the call site instead of letting a glob-shaped read compile against a
row carrying definitions.

In this repository the consumer cost is **zero sites outside
`packages/client` itself**. No other workspace package calls either read
member; the only in-tree mentions are `packages/client/README.md` (a
bare `await client.packages.list();` with no member read, type-checked
green by `check:skill-examples` against the rebuilt declarations) and
the pins in this PR.

**The three WRITE members did not move.** `install` / `enable` /
`disable` answer the row their own request contract produced —
`PackageInstallRequestSchema` declares `manifest: ManifestSchema` — and
PR objectstack-ai#17517 moved the read doors alone. That asymmetry is a measurement,
not an oversight, and it is pinned.

## Tests — type-level, with a control that can fail

`packages/client/src/return-type-precision.test.ts` is the only place a
return-type move CAN be pinned: nothing about the runtime values
changed, so a runtime test is green either way.

- **direction 1** — all four read members now ADMIT an assembled-stage
row;
- **the control** — the same assignment against the unmoved
`packages.install` is a used `@ts-expect-error`. If
`AssembledInstalledPackage` were assignable to `InstalledPackage` after
all, that suppression would go unused (TS2578) and direction 1 would be
exposed as passing vacuously;
- **direction 2** — a `manifest` belonging to NEITHER stage is still
refused, so this line reddens if anyone ever "widens" these members to
`any`, `unknown` or an open shape.

**Ablation** (restore `packages/client/src/index.ts` to blob
`c12b554d20` — the blob it carried before `21e6b9887c`, a **fixed**
anchor rather than the moving `origin/main`, keep the test file, run
`tsc --noEmit -p tsconfig.test.json`): exit **2**, **ten** errors — the
five `toEqualTypeOf` pins naming the union (TS2344) and **five** TS2322:
the four direction-1 assignments plus the new objectstack-ai#19324 gap pin. Zero
TS2578 — both controls stay USED in both states. The restore was
verified by blob identity against `HEAD` with `git diff HEAD` empty;
both `@ts-expect-error` controls stay USED in the ablated run, which is
why they are labelled GREEN IN BOTH STATES rather than offered as
evidence.

## Gates

Derived from the actual diff with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` on the merged head, every
exit code captured to disk before any pipe, and reconciled with `--ran`:

```
58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN  — all 58 exit 0
pnpm --filter '@objectstack/client^...' build   :: exit 0
pnpm --filter @objectstack/client typecheck     :: exit 0  (tsc + check:test-typecheck, 0 errors)
pnpm --filter @objectstack/client test          :: exit 0  (48 files / 566 tests)
pnpm lint                                        :: exit 0  — the FULL unnarrowed repo union, at 4fe43c0
```

Two of the 58 first answered `PREREQUISITE NOT MET`
(`check:skill-examples`, `check:dual-build-cjs-loads`), which is NOT
MEASURED and not a pass: both read built output and the packages had no
`dist`. They were built and re-run, and both then exited 0 on the merits
— `check:skill-examples` type-checks 258 prose examples across three
surfaces, including the client SDK's 23 blocks, against the rebuilt
declarations.

`origin/main` was merged once (`e3b3cdd2df`) before this PR opened;
build state was refreshed and the whole derived set plus the lint union
were re-run on the merged head.

## Acceptance notes

⚠️ **One finding, in `packages/spec`, reported rather than filed or
fixed** — this lane does not touch that package and does not file cards.
Measured on this branch, and it bears on how much the union actually
buys a consumer:

`AssembledInstalledPackage`'s `manifest` resolves to an index-signature
type (`Record` of `string` to `unknown`) in the PUBLISHED TypeScript
type, not to the assembled body's declared shape. The cause is the cast
in `packages/spec/src/api/package-api.zod.ts`:
`AssembledPackageRecordBodySchema` is built on
`(AssembledPackageBodySchema as unknown as z.ZodObject(z.ZodRawShape))`,
whose `z.input` is an index signature. Driven with `tsc`:
`InstalledPackage` IS assignable to `AssembledInstalledPackage` (an
authoring manifest satisfies an index signature), so the assembled arm
absorbs the authoring arm at the type level, and a member read off the
assembled stage's `manifest` arrives as `unknown` rather than its
declared type. The runtime Zod parse is unaffected — the schema still
checks the assembled body member by member, exactly as its own docblock
says. This is the same failure family `check:exported-any` exists for
("the snapshot records that an export exists, never what it resolves
to"), reached by a different spelling. Dedupe words:
`AssembledPackageRecordBodySchema`, `ZodRawShape`, `package-api.zod`,
`assembled manifest type erosion`, `z.input index signature`.

noted, not filed: `@objectstack/client` re-exports neither
`InstalledPackage` nor `InstalledPackageAtEitherStage`, so a consumer
writing an explicit annotation reaches into `@objectstack/spec/api` for
it. That is unchanged by this PR — the SDK has never re-exported the
package row — and adding a re-export would be a new published export on
a package this card is only correcting. Carrier: none; no queued PR or
lane touches this surface.

noted, not filed: the stale comment triage measured on `packages.get`
(it claimed `GetInstalledPackageResponseSchema` is `data:
InstalledPackageSchema`) is corrected in this PR rather than filed, per
that ruling's explicit instruction. It is corrected in place with the
reading that falsified it, not deleted.

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

---
_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

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants