Skip to content

fix(spec)!: PackageInstallRequestSchema's wrapped branch refuses an unknown top-level key by name - #20277

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20249-install-request-wrapped-strict
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20249-install-request-wrapped-strict

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20249
Clause-②: no (narrowing)

Dispatched by the domain:spec seat 4 PM, session session_01CiCTczDo7tGhafXjf61dUJ, round 1, on claim 5857174683. Ruling carried: decision batch #227 item 3, letter A (ruling record 5856869656, maintainer 「同意」).

What changed

  • PackageInstallRequestSchema, the wrapped branch of PackageInstallBodySchema (packages/spec/src/api/package-api.zod.ts): z.object (strip) became strictObject, with surface this package install request. An unknown top-level key is now refused by name, in the same envelope the manifest's and the bare form's unknown keys get. The key is named, a near-miss is offered the declared key, and the history sentence carries the remedy: remove the key, or spell the declared option it meant. There is no alias and no grace window. { manifest, enabledOnInstall: false } now answers:

    Unrecognized key(s) on this package install request: `enabledOnInstall`. Did you mean `enabledOnInstall` → `enableOnInstall`? Until this shape was closed, an unknown top-level key on the wrapped install body parsed green and was silently dropped, so a misspelled install option was ignored without a word — a caller who asked for the package DISABLED got it installed ENABLED. Remove the key, or spell it as the declared install option it meant; the declared keys are enumerated by `PackageInstallRequestSchema` (@objectstack/spec, api/package-api.zod.ts).
    
  • Docblock. The sentence 「⛔ Do NOT close the wrapped branch with .strict() … (ruling A)」 and the asymmetry paragraph around it are gone. In their place are this ruling's citation and its reason. The retired premise, «the declaration must not refuse a body the door answers 201 to», held only while the door did not parse its body. Since c02fa1276 the door answers what this union declares, so the premise is circular. The heading now reads "disjoint — and BOTH are closed". The request schema's own docblock gains a short "CLOSED" section that points at the union.

  • The install door's code is not edited. It refuses the body because it asks the declaration. Its install-branch COMMENT was made true in patch round 1 (0b352e98a, under claim amendment 5858369774). It used to open 「⚠️ What the union does NOT refuse, deliberately」 and now opens 「⭐ The TOP LEVEL of the wrapped form is closed too」. It states that PackageInstallRequestSchema is a strictObject, that an unknown top-level key fails the union by name, and that the door answers 400 / VALIDATION_ERROR and installs nothing. It cites ruling record 5856869656. Residual item 2 in package-api.zod.ts now cites 5856869656 inline. Both edits are comment-only: for both files, the parser's leaf-token stream (JSDoc excluded) and the comment-free print are identical at 5941e9aa5 and 0b352e98a, and a positive control reads DIFFER.

  • ADR-0087 D3 entry package-install-request-unknown-keys-refused (packages/spec/src/migrations/entries/semantic/18.…ts), plus the regenerated migrations/registry.ts region. spec-changes.json and docs/protocol-upgrade-guide.md were regenerated and did not change, because those projections do not render major-18 entries yet.

  • Changeset .changeset/20249-install-request-wrapped-strict.md:

    • @objectstack/spec minor, with a BREAKING for callers of the install door banner.
    • Clause-②: no (narrowing) on its own line, and the ADR-0087 marker registered package-install-request-unknown-keys-refused.
    • The FROM → TO migration for a misspelled option and for any other key.
    • The first-party reach (the SDK, and the objectui dialog), with out-of-repo callers stated as NOT MEASURED.

Pins

  • Spec door (packages/spec/src/api/package-api.test.ts, the wrapped-branch rows):
    • Three refusals: a misspelled enabledOnInstall, a private _source, and an unknown key beside declared options. Each asserts that the bound union refuses the body. Each also asserts the wrapped branch's single issue: code unrecognized_keys, path [], keys equal to exactly that key, and the key named in the message. The misspelling also asserts the offered enableOnInstall.
    • Controls: manifest alone; manifest plus every declared option, with each value carried through. The option list is read off .shape, so a new option cannot slip past the fixture.
    • The old "the WRAPPED branch DROPS an unknown key" pin is replaced.
  • Install door (packages/runtime/src/domains/packages-install-body-contract.test.ts):
    • §5 was a PARITY assertion («door and declaration agree, whichever way that is»). It stays green whichever way the declaration goes, so it could not tell strict from strip. It is now a status pin.
    • { manifest, enabledOnInstall: false } answers 400 VALIDATION_ERROR, naming `enabledOnInstall` and offering `enableOnInstall`. It is not installed, and it is not recorded as disabled.
    • _source gets the same answer, and nothing is installed.
    • Control: the same body spelled enableOnInstall: false answers 201 and is DISABLED in the registry and in the durable record.
    • §0 gains both bodies. The file header's "What is deliberately NOT refused" section is rewritten to match.

Evidence

  • Base. Branch cut from e46218674. origin/main a9fb83ef0 was merged in before this PR as 5941e9aa5. The merge was clean, registry.ts was regenerated, and check:migration-registry passed with 267 semantic entries. The build closure was then rebuilt and every run below was repeated at 5941e9aa5.
  • Build. turbo run build --filter='@objectstack/client^...' --filter=@objectstack/client: 33/33 tasks successful.
  • Tests at 5941e9aa5, all exit 0:
    • @objectstack/spec --project local: 550 files, 16244 passed, 1 todo.
    • @objectstack/runtime --project local: 281 files, 4019 passed, 1 skipped.
    • @objectstack/client: 50 files, 636 passed. It imports both schemas in readme-package-install-example.test.ts.
  • Typecheck (tsc --noEmit and check:test-typecheck): spec, runtime and client all exit 0.
  • Ablation at 469f5e3a7, through scripts/ablation-replace.mjs in wrap mode with an EXIT/INT/TERM trap. The merge touched neither ablated file nor either pin file.
    • Mutation: the anchor }).describe('Install package request')); became }).strip().describe('Install package request'));. Anchor count went 1 → 0, the replacement 0 → 1, and the blob c4f6b506b477 → 70f87496fc7b.
    • Spec file: 3 failed and 81 passed. The failures were the three refusal pins (the bound union refuses it: expected true to be false); the controls stayed green.
    • Runtime file: 3 failed and 29 passed. §5's two refusal pins failed with expected 201 to be 400, so the door installed under strip mode, and §0's premise failed too. The §5 control stayed green.
    • Restore: the blob equals the HEAD blob (c4f6b506b477) and git diff HEAD is empty.
  • Gates (dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 5941e9aa5): 88 derived, 86 exit 0, and 2 NOT MEASURED because their prerequisite is not met (exit 3). --ran reconciliation reports 0 UNRUN.
    • check:dual-build-cjs-loads and check:type-check-debt need a whole-workspace build. CI runs them.
    • Among the gates that passed: check-adr-0087-registration --base origin/main (it reads this changeset as [BREAKING+clause-②-narrowing] registered package-install-request-unknown-keys-refused (new here)), check-changeset-no-major, check-empty-changeset, check:api-surface, check:authorable-surface, check:docs, check:strictness-ledger, check:migration-registry, check:spec-changes, check:upgrade-guide, check:doc-authoring and check:nul-bytes.
  • Lint, narrowed on purpose (eslint --no-inline-config --format json over the 5 changed .ts files): 5 files, 0 errors, 0 warnings.
    • --print-config resolves all five. The changeset .md is outside eslint's configured population.
    • eslint.config.mjs enables no type-aware linting (no parserOptions.project or projectService), so this diff cannot change the verdict on any file it does not touch.
    • Repo-wide pnpm lint is left to CI.

Measured against the dispatch's assumptions

  1. Holds. On this base the door parses the whole body through PackageInstallBodySchema and refuses a wrapped unknown key without any runtime source edit. The door pin in §5 is the evidence.
  2. Partly held. §5 did not assert the strip. It asserted parity, which stays green in both directions, so it would not have flipped on its own. It is now a status pin, and the file is a test file only.
  3. Sentences touched in the PackageInstallBodySchema residual section ([finding] once PR #20218 lands, PackageInstallBodySchema's published docblock still says the install door answers 201 to residual classes 1b, 2, 3 and 4, which it then refuses 400 #20219's region; [finding] once PR #20218 lands, PackageInstallBodySchema's published docblock still says the install door answers 201 to residual classes 1b, 2, 3 and 4, which it then refuses 400 #20219 is not addressed here):

Acceptance notes


Generated by Claude Code

…unknown top-level key by name

The wrapped install body closes from strip to strict (strictObject), so
{ manifest, enabledOnInstall: false } is refused naming the misspelled key
instead of installing the package ENABLED with the key dropped. The docblock's
retired no-strict sentence is replaced by decision batch #227 item 3, letter A
(ruling record 5856869656) and its reason.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…and pin it at both doors

Adds the semantic entry package-install-request-unknown-keys-refused and the
regenerated migrations/registry.ts region, the minor BREAKING changeset with
its registered disposition, and turns the runtime install-body contract's
parity pin into a status pin for the wrapped top level.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…ct reference's defaulted format

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…wer before the declaration's

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

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/environment-routing.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))
  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))
  • content/docs/permissions/system-context.mdx (via handlePackagesRequest (symbol, a top-level function))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))
  • content/docs/ui/apps.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

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

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5941e9aa560effa737963aa686f8021596ecbcf2

Read: card #20249 body and its 3 comments (claim 5857174683, dev report 5858350043, amendment 5858369774); ruling record 5856869656 (#19328, batch #227 item 3, letter A — verified it is that ruling) and the retired record 5716042643 (#18058, batch #148 item 4); ruling B on #17152 (5615360777); PR #20277 object, body, 6 files, 5 commits, 35 check-runs; the full diff against merge-base a9fb83e; at the head: package-api.zod.ts, package-api.test.ts, packages.ts (:70–85, :675–775, :970–1075, :1098–1110, :1190–1205), packages-install-body-contract.test.ts, the D3 entry and its registry region, the changeset, strict-object.ts, suggestions.zod.ts, polarity-axes.ts, the three changeset-gate headers, pr-automation's WHICH LEVEL prose, the strictness-ledger gate and counts, the SDK install call, README and docs examples, the CLI integration/e2e bodies, hotcrm at 2f7b2326 (read-only). Ran, in a detached worktree at 5941e9a after pnpm install --frozen-lockfile, under os-verify-lock (VERDICT command-exit 0, held 62s): a tsx spec-door probe; turbo build of the runtime closure (29/29 cache hits); spec package-api.test.ts 84/84; runtime packages-install-body-contract.test.ts 32/32; ablation 1 (.strip() at the dev's anchor) and ablation 2 (bare z.object().strict(), helper removed), both restored to the HEAD blob with clean porcelain. NOT MEASURED: the objectui package dialog's body (no ../objectui checkout, console dist gitignored and unbuilt — taken from the ruling record); out-of-repo callers; the derived gate families and the full spec/runtime suites (CI's, read from the check-runs).

① Derived judgments

  • (a) The refusal — holds. At the spec door a wrapped body with an unknown top-level key gets ONE unrecognized_keys issue at path [] whose keys lists every unknown key (two at once: bogus, _trace both named in one issue), message Unrecognized key(s) on this package install request: + the key(s) in backticks, an optional Did you mean rename, then the history sentence carrying the remedy (remove it, or spell the declared option; declared keys enumerated by PackageInstallRequestSchema). That is the same strictObject envelope the manifest's unknown keys get (probe: inner bogus answers Unrecognized key(s) on this package manifest: at path ['manifest']; the bare form answers the same at path []). At the install door the union answers invalid_union, installBodyRefusal (packages.ts:715–747) re-parses the wrapped branch alone and surfaces its issue verbatim inside Invalid package install body (read as the WRAPPED form …) — (body): …, and deps.error(…, 400) yields VALIDATION_ERROR — the same expectRefused helper §2's manifest-inner and bare-form pins use. No alias: the strictObject options carry only surface and history (no aliases, guidance, extraKeys); no grace window: catchall.type=never, constructor ZodClosedObject, no env switch. Near-miss: the rename channel pairs the caller's own key (always drawn from issue.keys) with a declared key read off .shape (tombstones excluded), budget max(2, floor(len/3)); enabledOnInstall → enableOnInstall is at distance 1 and correct. It can never name a key the caller did not send. It can offer a distance-based guess that is a different word (overrides → overwrite, distance within budget 3) — phrased as a question, the key still refused, the target always a declared key; the opposite-pole path cannot fire on this shape (poles are start/end, min/max, none declared). The history sentence carries no example key, so the backticked-key and enableOnInstall assertions in both pin files are discriminating (ablation 2 below).
  • (b) The controls — hold. .shape = manifest + FIVE options: settings, enableOnInstall, overwrite, platformVersion, artifactRef (the card's "four" is wrong, the dev's five is right; the fixture is read off .shape with a pin that reds on drift). { manifest } alone and manifest plus all five round-trip at both doors with every value carried through (probe and the spec control pins). The SDK wire body { manifest, settings: undefined, enableOnInstall: undefined } survives JSON as { manifest } and parses. Nothing legitimate first-party is refused: packages/client/src/index.ts:2633–2647 sends only manifest / settings / enableOnInstall / overwrite; client/README.md:254,261 and content/docs/api/client-sdk.mdx:337 ride that call; packages/cli/test/package-restart-acceptance.integration.test.ts:313 and serve-package-registry-always-on.e2e.test.ts:252–254 post { manifest } alone; the runtime and spec suites at head are green in CI's Test Core; the cloud-environment install at client/src/index.ts:3372 posts to a different route; hotcrm at 2f7b2326e8f52adfc9efd00b68beda5348e71a1b has zero install-door POST callers (its two /api/v1/packages hits are GET list mentions in docs). objectui { manifest }: NOT MEASURED by me.
  • (c) The install door — holds. §5 asserts, in order: expectRefused (handled, status 400, success: false, error.code VALIDATION_ERROR, no data), then registry.getPackage(id) undefined against the real SchemaRegistry, then persistedDisabled().has(id) false against the durable state file, then the message contains backticked enabledOnInstall AND backticked enableOnInstall, then the declaration's verdict last; _source the same minus the suggestion; the control enableOnInstall: false answers 201, registry enabled === false, persisted disabled. "Nothing installed" is asserted, not inferred. Pins are real: ablation 1 (}).describe(...) → }).strip().describe(...), blob c4f6b506b477 → 70f87496fc7b, matching the dev's) reddened exactly the three spec refusal pins (the bound union refuses it: expected true to be false, 3 failed / 81 passed) and runtime §0 plus §5's two refusals (expected 201 to be 400, 3 failed / 29 passed) with every control green — the right mutation, since .strip() re-creates the pre-PR posture exactly. Ablation 2 (helper removed, bare z.object(...).strict()) kept status and code green but reddened the envelope assertions (spec: expected 'Unrecognized key: "enabledOnInstall"' to contain 'enabledOnInstall', 3 failed; runtime: the door message lacks the backticked key, 2 failed), so the pins hold the named-key envelope, not only the status. The §5 conversion is sound: the old expect(status).toBe(declared ? 201 : 400) parity was green in both directions; the new pin is load-bearing on status + code + the key named + nothing installed, and ablation 1 shows the door assertion reddening first.
  • (d) The docblock — holds. The 「⛔ Do NOT close the wrapped branch with .strict() … (ruling A)」 sentence, the asymmetry paragraph, and the "only ONE of them is closed" heading are gone; a grep of the head file for Do NOT close, STRIP mode, ruling A, 5716042643, asymmetr finds nothing. The new text cites the ruling record 5856869656 and the commit c02fa1276 (verified: PR fix(runtime): the package install door parses its whole body through PackageInstallBodySchema #20218's landing, 223 lines in packages.ts), with [finding] four residual classes the package install door still answers 201 to, measured unchanged by #19326 (triage: likely four cards) #19328 only as the convenience link — per AGENTS.md's citation rule. Every sentence is true at the head: both branches are strictObject (probe: ZodClosedObject, catchall never); { manifest, enabledOnInstall: false } is refused naming the key and offering enableOnInstall; no alias and no grace window; the door parses the whole body through the union (packages.ts:1016) and answers what it declares; the pins live in the two named files; the request schema's own CLOSED section points at the union.
  • (e) The [finding] once PR #20218 lands, PackageInstallBodySchema's published docblock still says the install door answers 201 to residual classes 1b, 2, 3 and 4, which it then refuses 400 #20219 region — holds. The residual section's only hunk is item 2 (@@ -396,8 +422,8 @@): "silently dropped on the wrapped one" → "and, since decision batch 🔗 Broken links detected in documentation #227 item 3, on the wrapped one too". The "five classes" lead-in, "201 either way", items 1a/1b, 3, 4 and 5 are byte-unchanged. The edited clause is true at the head (the wrapped branch refuses by name). The trailing "201 either way" stays false as it was before this PR — [finding] once PR #20218 lands, PackageInstallBodySchema's published docblock still says the install door answers 201 to residual classes 1b, 2, 3 and 4, which it then refuses 400 #20219's.
  • (f) The D3 entry — true under ruling B on [Decision] 一次退役,要写一条记录还是两条?—— 迁移条目的 D2/D3 约定,两处成文相互矛盾 #17152 (5615360777, resting on 5559778263: one D3 entry per family, naming the measured in-repo authors and the NOT-MEASURED out-of-repo population). One entry, one family (undeclared top-level keys on the wrapped install body); it names the family in surface, tells the author what to change in replacement (respell enabledOnInstall → enableOnInstall, remove any other key), names the measured first-party reach and says out-of-repo callers are NOT MEASURED, and carries all five SemanticMigration fields (id, surface, replacement, reason, acceptanceCriteria; surface has no backticks). The registry region is a verbatim mirror: the 50 added lines equal the entry file's comment block plus fields after removing the 4-space indent. check:migration-registry, check:spec-changes, check:upgrade-guide are inside the green TypeScript Type Check job. The tracker-shaped batch #227 in reason is in-family (105 batch #N mentions in the registry at base) and outside check:doc-authoring's Rule 3 population; the history string a caller is shown carries no tracker id.
  • (g) The export and accept-set diff — the narrowing is exactly the wrapped top level. api-surface/api.json and export-origins/api.json are identical at base and head (both list PackageInstallRequestSchema (const) and PackageInstallBodySchema (const); they record existence, not strictness); check:api-surface, check:export-origins, check:authorable-surface are green. The strictness ledger's api/ row is an untriaged site total (432, counting z.object and strictObject alike), so no ledger artefact moves. Nested: settings is an open record (probe: arbitrary keys accepted, unchanged); artifactRef is ArtifactReferenceSchema in marketplace.zod.ts, unchanged base→head (it still strips an unknown key inside artifactRef — pre-existing, outside this card); manifest is ManifestSchema, unchanged. The bare form is ManifestSchema, unchanged, and refuses bogus exactly as before.

② Semver level

Declared: @objectstack/spec minor, a BREAKING for callers of the install door banner, Clause-②: no (narrowing) line-leading, marker registered package-install-request-unknown-keys-refused. Against the gates' stated rules this is right. check-changeset-no-major.mjs's header: during the launch window a breaking change ships as minor and breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the level; its level axis stands down on a no declaration. AGENTS.md Post-Task 3: (narrowing) is BREAKING and no (narrowing) is the well-formed spelling. check-adr-0087-registration.mjs's header: registered requires every id to resolve at HEAD and at least one to be NEW in the diff (the 18.… entry is new), while no-migration-prescription is REFUSED when the body carries a migration prescription — this changeset carries a FROM → TO, so the card's "wording of #19120 / #19417" (both not-required (no-migration-prescription), checked) could not be copied literally; the ruling record's item 2 and the card's item 4 both say registered, so the dev's deviation follows the ruled disposition and borrows only the prose shape. Nothing authorable is removed or renamed, so no tombstone is owed; the refusal itself carries the prescription. Check Changeset is green at the head.

③ Boundary flags

Blocking: none
Non-blocking: (1) owed by the patch round — packages/runtime/src/domains/packages.ts :1003–1011, the install-branch comment; PR #20277 at 5941e9a leaves packages.ts untouched (fenced by the claim, widened by amendment 5858369774). Exactly two sentences are false at the head: "⚠️ What the union does NOT refuse, deliberately: an unknown key at the TOP LEVEL of the wrapped form." and "PackageInstallRequestSchema is a plain z.object (strip mode) and its docblock forbids closing it, so { manifest, bogus } parses green with bogus dropped" — the union refuses it, the branch is a strictObject, and the docblock now records the close. The block's remaining sentences ("— and this door, reading only parsed keys, now does exactly what the declaration says." and "Refusing it here without the declaration would make the published contract admit a body the door refuses; that is the declaration's decision to make.") stay true, as do the other packages.ts sentences naming the schema (:77, :679–735, :1104, :1196–1200); the comment is not worse than the ruled falsity. (2) package-api.test.ts :1080–1097 DOOR_201_RESIDUALS: the title and comment "the door answers 201 to all of them anyway — so this declaration is a SUBSET of the door" have been false since c02fa12 (the door answers 400); its assertions (the declaration refuses each body) are still true, none of its bodies is a wrapped top-level unknown key, and this PR neither touches it nor changes its truth — #20219's class, not blocking for this PR. (3) The residual section's "additionally answers 201 to five classes" lead-in and item 2's trailing "201 either way" remain false as before — #20219's, declared by the dev. (4) Cosmetic: the edited item-2 clause cites "decision batch #227 item 3" without the record id inline; the record id 5856869656 is cited 30 lines above in the same docblock. (5) Observation outside the card: ArtifactReferenceSchema (marketplace.zod.ts, unchanged here) still strips an unknown key inside artifactRef — pre-existing, no carrier named here. (6) The Acceptance notes are accurate: the fenced comment, the stale pin text, four→five options, the disposition deviation, the SDK re-read, objectui taken from the ruling record and not re-measured, hotcrm zero callers, out-of-repo NOT MEASURED. Closing keywords: the body closes exactly Fixes #20249; #19328 does not appear in the body; #20219 appears five times as prose only, never under a closing keyword.

CI at this head: 35 check-runs on 5941e9a, 0 pending — 32 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)); all seven required contexts success: Lint & Repo Gates, TypeScript Type Check, Test Core (6/6 shards), Dogfood Regression Gate (3/3 shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard; Check Changeset, Spec property liveness, Type Check · source/consumer/debt/workspace all success. The PR is a draft, not governed (no governed path among its 6 files), size/m at +352/−49, merge base a9fb83e.

Implemented-by: claude/issue-20249-install-request-wrapped-strict
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: PASS

… level is closed, and item 2 cites its ruling record

The install branch comment in packages/runtime/src/domains/packages.ts said
PackageInstallRequestSchema was strip mode and forbidden to close; since the
wrapped branch became a strictObject that is false. It now states the close,
the door's 400 VALIDATION_ERROR with nothing installed, and cites ruling record
5856869656. The residual item-2 clause in package-api.zod.ts gains the same
record id inline. Comments only; no code token moves.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0b352e98aa1b9f641219c60296aea4b0b8ea3612

Delta from 5941e9a (PASS 5858576330): exactly one commit, 0b352e98a (parent 5941e9a, no merge of main), touching packages/runtime/src/domains/packages.ts (+11/−8) and packages/spec/src/api/package-api.zod.ts (+2/−1), every changed line a // or * comment line; the PR body was edited by the seat at 19:33:54Z. Read: PR #20277 object, body, 7 files, 6 commits, 0 reviews, 42 check-runs and 17 workflow runs on the head; ruling record 5856869656 (on #19328, batch #227 item 3, letter A — verified), amendment 5858369774 and dev report 5859104116 (both on #20249), the prior record 5858576330; at the head: packages.ts :600–760 and :960–1215, package-api.zod.ts :195–470, http-dispatcher.ts error(), error-envelope.ts :120, errors.zod.ts HttpStatusErrorCodeMap[400], the changeset, the D3 entry, marketplace.zod.ts :104, package-api.test.ts :1080–1110, runtime package.json files and tsup.config.ts, scripts/tsup-drop-sources-content.mjs; at origin/main 733822c: the 14 commits and 103 files past merge base a9fb83e, build-migration-registry.ts, the check-error-status-conformance.mjs and doc-authoring-prose-id.baseline.json diffs, spec-changes.json and the upgrade guide. Ran, in my scratchpad only, no build: a TypeScript-compiler-API comment-stripped token comparison (global typescript 6.0.2) base→head on both files with three controls; a driverless bare-clone merge-tree --write-tree of the head onto 733822c; a faithful port of the registry generator's loadEntries/renderRegistry over the merged tree with two controls. NOT MEASURED: the derived gate families and the spec/runtime suites (CI's, read from the check-runs); main's own CI at 733822c (still in progress at read time, none red); the objectui dialog and out-of-repo callers (unchanged from the prior record).

① Derived judgments

  • (a) The rewritten packages.ts paragraph (:1003–1013 at the head) — every sentence true. Sentence 1, «The TOP LEVEL of the wrapped form is closed too — by the declaration, not by this door»: PackageInstallRequestSchema is strictObject({...}) at package-api.zod.ts:228, and the door carries no line of its own for a top-level key. Sentence 2: the ruling citation is exact (comment 5856869656 on [finding] four residual classes the package install door still answers 201 to, measured unchanged by #19326 (triage: likely four cards) #19328 opens «Ruling: batch 🔗 Broken links detected in documentation #227 item 3 · letter A»); { manifest, enabledOnInstall: false } fails the union — PackageInstallBodySchema.safeParse(body) at :1019 (the wrapped branch's unrecognized_keys names the key, the bare branch refuses manifest and lacks id); the verdict at :1155–1160 returns deps.error(installBodyRefusal(...), 400) BEFORE installPackage (:1193–1198), so nothing is installed; deps.error(msg, 400) → apiErrorResponse (http-dispatcher.ts:1057–1062) → code: input.code ?? promoted ?? standardErrorCodeForHttpStatus(400) (error-envelope.ts:120) → HttpStatusErrorCodeMap[400] = 'VALIDATION_ERROR' (errors.zod.ts:184); installBodyRefusal (:717) re-parses the wrapped branch alone and surfaces the strict close's own sentence, so the key is named — pinned by the CI-green §5 rows; «reading only parsed keys» holds: request (:1164), overwrite (:1171), settings (:1189) read the parsed value. Sentence 3, the restated rule «⛔ Never refuse or admit a key here that the declaration decides the other way», is true and uncontradicted in the file: the id and version legs parse the declaration's own shapes by reference (:1093, :1139); the !pkgId gate (:1023) refuses '', which MANIFEST_ID_PATTERN refuses too (:1079 says so); the 409 (:1173) is server state, not a key; ?overwrite=true is a query parameter; the nested-shape note at :1186 says the door does NOT answer for what the declaration leaves open — the same rule from the other side. Retired reasoning gone: a grep of the head file for strip mode, forbids closing, plain z.object, does NOT refuse, parses green, bogus, ruling A, 5716042643 finds nothing (the base file had :1003 and :1005); the only strip hits are the pre-existing nested-manifest note (:1186) and the unrelated [#15591] read-time decoration strip (:2276–2297). Citation: the paragraph cites the ruling record 5856869656 and no tracker number at all (batch 🔗 Broken links detected in documentation #227 is a decision-batch id). Rest of the block re-read (:970–1018): the [#19328] opener, the «Until this line…» history (its unknown-key row names the manifest and the bare body only), the 基本裁决原则 paragraph, the «a caller that needs an option sends the wrapped form» quotation (still at package-api.zod.ts:443) and «The verdict is answered after the id and version legs below» (:1093, :1139, :1155) are all true at the head. One observation, not a falsity: the sentence «⛔ Nothing in packages/spec moves, and neither branch is relaxed» (:996–998) describes the door-parse change c02fa1276 it sits under and is true of that change; read out of its paragraph it could pass for a standing prohibition that ruling 5856869656 has since retired for the wrapped top level — no sentence is false, nothing owed.
  • (b) The package-api.zod.ts clause — reads exactly «since ruling record 5856869656 (decision batch 🔗 Broken links detected in documentation #227 item 3), on the wrapped one too, 201 either way;» (:426–427). The file's whole base→head diff is that one hunk (@@ -423,7 +423,8 @@): the «five classes» lead-in, items 1, 1a, 1b, 3, 4, 5, the «⛔ None of these is a licence…» and the bare-form paragraphs are byte-unchanged. The docblock's 5856869656 citation at :398 and the strictObject sentences at :376–396 are the prior head's, unchanged.
  • (c) No code token moved — verified independently. Instrument: ts.createSourceFile per blob, a forEachChild leaf walk (JSDoc never visited, getText() excludes trivia) plus createPrinter({ removeComments: true }). Base 5941e9a → head 0b352e9: packages.ts 3268 → 3268 leaf tokens, stream sha ff054c620628a053 both, print sha 87b406412a664db8 both, IDENTICAL; package-api.zod.ts 677 → 677, stream sha dcc984d4be809469 both, print sha 1231cdb26b0c6c66 both, IDENTICAL (both print shas equal the dev's reported ones, reproduced here from the blobs). Controls: e462186 → 5941e9a on package-api.zod.ts (round 1's real change) reads DIFFER at token [WIP] Add support for default language configuration #18 (lazySchema vs strictObject, 664 → 677, exit 1); a one-token mutation of the head blob (400 → 401 in the refusal line) reads DIFFER at token fix(studio): replace custom getRequestListener with handle() from @hono/node-server/vercel #1001 (exit 1); a comment-only append reads IDENTICAL (exit 0). Parse diagnostics 0 throughout.
  • (d) Nothing else moved since 5941e9a: git log 5941e9aa5..head is the single commit 0b352e98a; git diff --name-status lists exactly the two files; the 19 changed lines in the diff are all comment lines. The changeset, the D3 entry, registry.ts, both test files, marketplace.zod.ts and package-api.test.ts are byte-unchanged. The commit trailers are the model-free pair; no model identifier in the commit, the comments or the body.

② Semver level

Unchanged and right. The changeset .changeset/20249-install-request-wrapped-strict.md is byte-identical to 5941e9a: '@objectstack/spec': minor, the BREAKING for callers of the install door banner, Clause-②: no (narrowing) on its own line, and the marker adr-0087: registered package-install-request-unknown-keys-refused; the D3 entry 18.package-install-request-unknown-keys-refused.ts resolves at the head. Check Changeset is success on the head (both runs). A comment-only runtime edit adds no obligation, measured: packages/runtime/package.json publishes files: ["dist", "README.md", "CHANGELOG.md"]; tsup.config.ts bundles src/index.ts through esbuild (format: ['esm','cjs']), which drops every non-legal comment and the edited lines are // lines inside handlePackagesRequest's body, not legal comments and not on an exported declaration, so neither the JS bundles nor the .d.ts carry them; sourcemap: true ships positions only, because esbuildOptions: dropSourcesContent sets sourcesContent: false (scripts/tsup-drop-sources-content.mjs). No published byte of @objectstack/runtime changes.

③ Boundary flags

Blocking: none
Non-blocking: (1) Merge risk, measured: origin/main is 733822cf8, 14 commits past merge base a9fb83e (the dev counted 10 at ae8e3ca; four landed since). A driverless probe (bare clone with no os-regen driver, objects via alternates) merge-tree --write-tree 733822cf8 0b352e98a is clean, tree 7d6d1cf40, zero conflicted paths. The only file both sides touch is the generated packages/spec/src/migrations/registry.ts (main: +182/−… across 17.engine-* and 18.engine-dotted-filter rewrites plus the new 18.cel-predicate-one-value entry; PR: its +50 region). A faithful port of build-migration-registry.ts (loadEntries sorted by major then id, renderRegion, renderRegistry) over the merged tree regenerates the merged registry.ts byte-identically (268 semantic, 214 retired-key, 199 retired-def entries, 0 errors); control A over the head tree alone is identical (267), control B over the merged tree minus the PR's entry is not — so check:migration-registry would pass on the merge ref. spec-changes.json and docs/protocol-upgrade-guide.md at main still render no major-18 entry (main's own new 18.cel-predicate entry is absent from both), so the PR's entry is not owed there. Gate scripts main changed: check-error-status-conformance.mjs (corpus-walk robustness for entries that vanish mid-walk, plus its self-test batteries — no rule change, and the PR's runtime edit is comment-only), scripts/pm/dispatch-gates.mjs, post-stamped.mjs, record-recognisers.mjs (self-tests over main's own files in Lint & Repo Gates), doc-authoring-prose-id.baseline.json (a shrink in the packages/lint region; check-doc-authoring.mjs itself unchanged), dropped-refinements.baseline.json, liveness/permission.json, packages/cli/vitest.config.ts — none reaches the PR's seven files. Bound on what CI already saw: every PR run on this head was created 19:08:52Z or 19:33:58Z (runs record base 17bd318); 2aa25efb4 (19:09:30Z) and 733822cf8 (19:15:45Z) are outside all of them and are covered by the probe above. NOT MEASURED to conclusion: main's own CI at 733822c (64 success, 15 skipped, 14 in progress, none red at read time). (2) PR body: truthful about the head in every checked claim — the new opener 「⭐ The TOP LEVEL of the wrapped form is closed too」, the three stated facts, the 5856869656 citations in both files, the comment-only token proof (reproduced), the amendment and report ids, the 19:33Z edit, the at-tier record id, base/merge history, 7 files / +364/−57. One cosmetic staleness: «Measured against the dispatch's assumptions» item 3 quotes residual item 2 in its round-1 wording («since decision batch #227 item 3») while «What changed» and the Acceptance notes state the patched wording; the head reads the patched one. (3) The prior record's non-blocking items are unchanged and still not blocking: package-api.test.ts:1080–1110 DOOR_201_RESIDUALS and its «the door answers 201 to all of them anyway» title (#20219's class; the file is byte-unchanged since 5941); the residual section's «five classes» lead-in and «201 either way» (#20219's, byte-unchanged); ArtifactReferenceSchema (marketplace.zod.ts:104) still a plain z.object, unchanged, no carrier named. (4) Closing keywords: the body closes exactly Fixes #20249; #19328 does not appear; #20219 appears five times as prose, never under a closing keyword. (5) Landing state: draft, not governed (no governed path among the 7 files), 0 reviews, auto-merge off, mergeable_state clean, size/m.

CI at this head: 42 check-runs on 0b352e9, 0 pending — 37 success, 5 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), and the 19:33:58Z edited-event re-runs of Auto Label and Check PR Size); all seven required contexts success: Lint & Repo Gates, TypeScript Type Check, Test Core (6/6 shards), Dogfood Regression Gate (3/3 shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard; Check Changeset, Spec property liveness, Type Check · source/consumer/debt/workspace, Dogfood Verify CLI, the four claim guards all success. 17 workflow runs, all completed, none failed.

Implemented-by: claude/issue-20249-install-request-wrapped-strict
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 19:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 28ad7e4 Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20249-install-request-wrapped-strict branch September 27, 2026 20:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: PackageInstallRequestSchema's wrapped branch refuses top-level unknown keys by name — ruling A on #19328

2 participants