Skip to content

fix(metadata-protocol)!: installPackage refuses a manifest the install declaration refuses (type, scope, unknown keys) - #24203

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24086-install-primitive-parses-manifest
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24086-install-primitive-parses-manifest

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24086

Clause-②: no (narrowing)

What changed

ObjectStackProtocolImplementation.installPackage (packages/metadata-protocol/src/protocol.ts) parsed only manifest.id, so an in-process caller stored manifests that POST /api/v1/packages answers 400 VALIDATION_ERROR for. cloud's AI authoring (ensureNamedBasePackage, ensureAppPackage) stored type: 'application' / scope: 'environment' this way.

  • The primitive judges the whole manifest with the door's declaration. After the id leg and the version: '0.1.0' default, and before the namespace default and any write, the manifest is parsed through PackageInstallManifestSchema (@objectstack/spec/api). That is the declaration the door parses, ManifestSchema with scope narrowed to project. It is not ManifestSchema, which still admits cloud / system (the domain:spec seat's note 6231970586). Nothing in this file decides which values are allowed: no enum check, and no copy of the door's rule.
  • The refusal throws { statusCode: 400 }, the same spelling as the id leg. An HTTP boundary (resolveThrownHttpError) answers it 400 VALIDATION_ERROR. The message is Invalid package manifest — manifest.KEYPATH: ISSUE_MESSAGE; … (ISSUE_MESSAGE is the declaration's own wording), so a platform scope carries packageInstallScopeRefusal's sentence verbatim. The door's installBodyRefusal is module-private in @objectstack/runtime, which depends on this package, so it cannot be reused here. This file surfaces the zod path and message instead.
  • The parse is a gate, not a normaliser, as at the door. The parse's defaults (scope, defaultDatasource) are not copied onto the stored manifest.
  • Stored rows keep hydrating (the card's second bullet). The read-back path is PackageServicePlugin.start in @objectstack/service-package, which hydrates sys_packages into the registry. It never judged type / scope. That tolerance is now stated in a comment at the seam and pinned on the real stack. It applies to the read only, and a new write of the same manifest is refused.

File surface

All edits stay inside the claim's surface: protocol.ts's installPackage region, the hydration read-back (packages/services/service-package/src/index.ts, comment only), pins, and the changeset. The pin sweep also touched fixtures outside metadata-protocol that feed this rule (listed below). No source edit was made in packages/spec or packages/runtime.

Pins (the flipped answer is load-bearing)

  • packages/metadata-protocol/src/protocol.install-manifest-declaration.test.ts (new). Each refused manifest is checked for resolveThrownHttpError(err) = { status: 400, code: 'VALIDATION_ERROR' }, for the key path it names, and for neither writer having run. The refused manifests are type: 'application', scope: 'environment', scope: 'cloud', scope: 'system', a missing type, and an unknown key (pages). The cloud case also asserts the declaration's sentence is surfaced verbatim. The id leg still answers first with manifestIdRefusal. Lit controls: a versionless { id, name, type } installs with 0.1.0 and a derived namespace, and the stored manifest gets no parse defaults.
  • packages/services/service-package/src/install-primitive-declaration.test.ts (new) runs on the real stack: ObjectKernel, ObjectQLPlugin, SqlDriver on better-sqlite3, PackageServicePlugin and the real protocol. application, environment and cloud are each refused with 400 / VALIDATION_ERROR, and afterwards registry.getPackage is undefined and the sys_packages row is null. The versionless control survives a restart. The H3 pin publishes a type: 'application', scope: 'environment' row through the service's own publish, the write the primitive used to make. After a restart that row is hydrated as stored (source: 'authored', values intact, no error line), and a fresh install of the same manifest is refused.
  • Flipped pin: hydration-convert-forward.test.ts › "the install primitive is authoring-side and stays strict". The assembled HotCRM body (with pages) used to reach the registry, which refused it 422 MisplacedDefaultPageError. The install declaration's strict close now refuses it first, so the pin asserts 400 / VALIDATION_ERROR naming pages, and still that nothing is written. Its docblock was updated to match.

Fixture triage (each fixture got its declaration added, not the rule relaxed)

The rule's consumer radius covers every caller of protocol.installPackage and every duplicatePackage source.

  • 7 metadata-protocol test files installed { id, name } or { id, version } with no type. Each now writes type: 'app', plus name where it was absent. The declaration requires both, and the door has always refused a manifest without them.
  • 8 objectql protocol test files used type: 'application', the off-spec value this card is about, as did protocol.flow-canonicalizer.test.ts and the app.iojn duplicate source in protocol-package-lifecycle.test.ts. Each was respelled type: 'app'. The scope: 'environment' kept on that duplicate source is stripped by duplicatePackage itself.
  • Not touched, because they never reach the primitive: runtime/src/app-plugin.disabled-seed.test.ts (code boot), and service-package hydration.test.ts / mysql2-tuple.test.ts (stored rows and the service's own write).

Mechanism hypotheses, measured

  • H1 (defaults versus the parse): holds, with one refinement. PackageInstallManifestSchema requires id, name, type and version, and namespace is optional. Placing the parse after the version default keeps the DB-created base packages vanish from GET /packages after server restart #2532 versionless install working. type has no default anywhere: the declaration requires it and the door refuses its absence. So a manifest without type is refused (settled by the four axes, see open_questions in the report). The in-repo callers that reach the primitive outside the door are duplicatePackage (it copies the source's type) and the unmanaged install's base (it copies type as an identity key). The door parses the same declaration first.
  • H2 (every caller is an authoring install): holds. The in-repo callers of protocol.installPackage are the runtime door (packages/runtime/src/domains/packages.ts), duplicatePackage (it deletes scope), and importUnmanagedPackage (its scope key is a base disposition, not copied). None installs a platform scope. Platform packages reach the registry through ObjectQL.registerApp, which does not go through this primitive.
  • H3 (hydration path): holds. PackageServicePlugin.start → registry.installPackage(convertForward(rec.manifest), 'authored') judges only the protocol handshake and the registry's own rules. The tolerance is stated there and pinned.
  • H4 (import edge): holds. PackageInstallManifestSchema comes from @objectstack/spec/api, which protocol.ts already imports. check-undeclared-dep-imports exit 0.
  • H5 (envelope): holds. The door answers deps.error(msg, 400) → VALIDATION_ERROR (standardErrorCodeForHttpStatus(400)). The primitive throws statusCode: 400, which resolveThrownHttpError resolves to the same { 400, VALIDATION_ERROR } (pinned).

Serial constraint with the producer

From triage 6228198471, verbatim: "⛔ Serial with the producer. The producer half is objectstack-ai/cloud#2948 (open, pm:blocked, assigned). The cloud pin that carries this refusal must also carry cloud#2948's change; otherwise cloud's AI authoring is refused on the first new base." objectstack-ai/cloud#2948 remains open as the producer half; this PR leaves it untouched.

The producer half's change is objectstack-ai/cloud#2978, the cloud PR for objectstack-ai/cloud#2948 (named by the repo:cloud seat 2 note 6236742886 on #24086). The cloud pin that carries this refusal must carry that merge. (Seat edit: the dev's addendum 6237799160 asked for this line, because the dev writes the body only once.)

Verification

All readings are on head e9ac2429aa (origin/main 7d6ec91d2f merged in, clean, with no overlap with the touched packages), in a cold cloud container. Builds and tests ran through scripts/pm/os-verify-lock.sh.

  • Build. pnpm --filter '@objectstack/metadata-protocol...' build, pnpm --filter '@objectstack/service-package...' build, and a rebuild of @objectstack/spec and @objectstack/metadata-protocol after the merge: VERDICT command-exit 0.
  • Tests on the final head:
    • @objectstack/metadata-protocol: 244 files passed, 3 skipped; 20638 tests passed, 19 skipped.
    • @objectstack/service-package: 9 files / 124 tests passed.
    • @objectstack/objectql src/protocol-*: 54 files / 715 tests passed.
  • Before the fixture triage: metadata-protocol had 34 failures across 7 files. Every one was a fixture manifest without a declared type (47 manifest.type issues, 4 manifest.name). That is the consumer radius the triage covered.
  • Typecheck: metadata-protocol, service-package and objectql (tsc --noEmit plus check:test-typecheck, debt held) all pass. The first service-package run failed with TS2307 @objectstack/objectql because objectql had no dist in this cold container. The pre-existing hydration-convert-forward.test.ts failed the same way. It was green after building the closure.
  • Ablation (one time, through scripts/ablation-replace.mjs, run against src). Both suites resolve metadata-protocol from source: ./protocol.js, and service-package's vitest alias. The mutation changed if (!declaredManifest.success) { to if (false && ...). Anchor 1 to 0, blob 115a51f6 to 0f44c5c3.
    • protocol.install-manifest-declaration.test.ts: 7 failed, 3 passed. The 6 refusals and the surfaced-sentence pin went red. The id-order pin and both lit controls stayed green.
    • service-package (install-primitive-declaration and hydration-convert-forward): 5 failed, 5 passed. The real stack registered and stored the off-spec manifests, which reproduces the defect. The hydration half of the H3 pin stayed green and only its fresh-write assertion went red, as designed.
    • Restored: blob equals HEAD (115a51f6), and git diff HEAD is empty.
  • Changeset gates:
    • pnpm check:adr-0087-registration: exit 0. It reads [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription).
    • node scripts/check-changeset-no-major.mjs: exit 0, "This diff introduces no major bump".
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) printed 69 commands on e9ac2429aa. All 69 ran with exit codes captured before any pipe, and all 69 exited 0. The --ran reconciliation reads: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3)".
  • Earlier pass on the pre-merge head (58 commands). It caught a real red, check:test-source-alias: the new service-package pins imported @objectstack/spec/api without an alias. I fixed it with one anchored alias in packages/services/service-package/vitest.config.ts, as the gate prescribes. Two gates refused on the shallow clone (check-engine-split-ratio --days 90, check-plugin-teardown-shape --self-test) and passed after git fetch --unshallow.

Acceptance notes

  • duplicatePackage is a re-entry path for stored manifests. Duplicating a pre-change stored base or app (type: 'application', scope: 'environment', an icon) is refused 400 VALIDATION_ERROR naming manifest.type and icon. The refusal is reported to the caller, and nothing is written to the registry or to sys_packages; this is pinned in packages/services/service-package/src/install-primitive-declaration.test.ts at head a263c0885f. The stored rows themselves keep hydrating. Repairing them belongs with the producer change or a migration, as recorded in the report's open_questions. (Seat edit, from the dev's addendum 6237799160.)

🤖 Generated with Claude Code

https://claude.ai/code/session_012HLZ1LzgJX5mNE9C4DqDYE


Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/service-package, touching 3 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-package/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 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 in installPackage))
  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage), /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage), /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage))
  • content/docs/ui/apps.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage))

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

  • content/docs/releases/v12.mdx (via installPackage (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage), /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage), /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/releases/v17/17-5.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage))
  • content/docs/releases/v17/17-7.mdx (via /api/v1/packages (route, a path literal in a comment in installPackage))

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

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8a88901be1c317e2e078e6d29f21efcae5b9485e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8a88901be1c317e2e078e6d29f21efcae5b9485e

⚠️ 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 8a88901be1c317e2e078e6d29f21efcae5b9485e → 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: a263c0885f1376f32d866f2c106b88bf90187608
Local-runs: none
Scope: packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol.install-manifest-declaration.test.ts, packages/metadata-protocol/src/durable-package.test.ts, packages/metadata-protocol/src/protocol-handshake-install.test.ts, packages/metadata-protocol/src/protocol.package-persist-failure.test.ts, packages/metadata-protocol/src/protocol.install-manifest-id.test.ts, packages/metadata-protocol/src/protocol.duplicate-package-install-refusal.test.ts, packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts, packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts, packages/objectql/src/protocol-install-package.test.ts, packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts, packages/objectql/src/protocol-duplicate-package-names-in-step.test.ts, packages/objectql/src/protocol-duplicate-package-objects-key-collision.test.ts, packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts, packages/objectql/src/protocol-package-lifecycle.test.ts, packages/objectql/src/protocol-references-permission-field-keys.test.ts, packages/objectql/src/protocol-save-permission-fls-key-forms.test.ts, packages/services/service-package/src/index.ts, packages/services/service-package/src/install-primitive-declaration.test.ts, packages/services/service-package/src/hydration-convert-forward.test.ts, packages/services/service-package/vitest.config.ts + packages/spec/src/api/package-api.zod.ts (PackageInstallManifestSchema, PackageInstallBodySchema, packageInstallScopeRefusal), packages/spec/src/kernel/manifest.zod.ts (ManifestSchema), packages/spec/src/kernel/plugin.zod.ts (CORE_PLUGIN_TYPES), packages/runtime/src/domains/packages.ts (the POST /packages branch, installBodyRefusal), packages/metadata-protocol/src/package-unmanaged-install.ts and packages/spec/src/kernel/unmanaged-install.ts (the unmanaged base's keys), packages/spec/src/kernel/namespace-prefix.ts (deriveNamespaceFromPackageId), packages/spec/src/kernel/package-registry.zod.ts (InstallPackageRequestSchema), packages/types/src/thrown-http-error.ts (resolveThrownHttpError), packages/spec/src/api/errors.zod.ts (400 → VALIDATION_ERROR), packages/objectql/src/registry-default-record-page.test.ts (the registry's page refusal keeps a pin), scripts/check-adr-0087-registration.mjs and scripts/check-changeset-no-major.mjs (marker vocabulary, launch-window level), and the prose face .changeset/24086-install-primitive-parses-manifest.md read for ②.

① Derived judgments

  1. What protocol.installPackage now refuses, and the declaration each refusal follows from — all RIGHT. The gate is PackageInstallManifestSchema.safeParse(manifest) (protocol.ts 31429), the schema POST /api/v1/packages parses on both body forms (PackageInstallRequestSchema.manifest and the bare branch of PackageInstallBodySchema, package-api.zod.ts 230, 289, 517). It is ManifestSchema.extend({ scope: z.enum(['project']) }), so the new refusals are exactly: type outside the ManifestSchema enum (plugin, ui, driver, server, app, theme, agent, objectql, module, gateway, adapter; application is not a member and type has no default); a missing type or name (both required, manifest.zod.ts); scope other than project (environment, and the platform scopes cloud / system, refused with packageInstallScopeRefusal's own sentence); any undeclared top-level key (ManifestSchema is a strictObject and .extend() keeps the close — pages, icon); and, by the same declaration, an explicit namespace or version the field regexes refuse, a tombstoned key, and a nested-shape violation inside a declared block. Each refusal throws { statusCode: 400 }, which resolveThrownHttpError resolves to { status: 400, code: 'VALIDATION_ERROR' } (standardErrorCodeForHttpStatus(400)), the envelope the door's deps.error(msg, 400) answers. No value is decided in this file: no enum copy, no hand-written rule; the message is Invalid package manifest — manifest.KEYPATH: ISSUE with the declaration's own issue text.

  2. Door versus primitive — RIGHT, with one pre-existing asymmetry named. The door (packages.ts 1227–1366) parses the raw body through PackageInstallBodySchema after its own id and version legs, then hands manifest = body.manifest || body — the manifest as SENT, not the parsed copy — to protocolSvc.installPackage({ manifest, settings }) (1423). The primitive re-parses that same object through the same sub-schema, so nothing the door admits is refused by the primitive. In the other direction the primitive admits one thing the door refuses: a manifest with no version, because the DB-created base packages vanish from GET /packages after server restart #2532 default (version = '0.1.0', protocol.ts 31398) runs before the parse. That is deliberate and pre-existing (the primitive admitted it before this diff too), the stored row is declaration-conformant after the default, the door's own version leg is untouched, and the changeset states it under Unchanged — so it is not a widening and not this card's target (the card's measured defect is the in-process type / scope rows). namespace: the parse runs before the derivation; the declaration's namespace is optional, so an explicit value is judged and the derived one is not — and deriveNamespaceFromPackageId answers null unless NAMESPACE_RE matches, so a derived namespace always satisfies the declared regex. type: no default exists anywhere (declaration, door, primitive), so refusing a typeless manifest is the door's own answer (class 1b in package-api.zod.ts's residual table) and Prime Directive Add comprehensive test suite for Zod schema validation #12 forbids inventing one in a consumer. Ordering against the ADR-0087 D1 handshake: the parse now runs before assertProtocolCompat, shape before semantics; a manifest both off-spec and protocol-incompatible answers 400 rather than ProtocolIncompatibleError, and the handshake fixtures gained name / type so they still reach the handshake. The parse result is not written back (declaredManifest.data unused), so scope / defaultDatasource defaults are not stored — the same gate-not-normaliser discipline the door records, pinned in both new test files.

  3. Every caller is an authoring install — RIGHT. At this head the primitive has three non-test callers: the door; duplicatePackage (protocol.ts 27526–27567: spreads the stored source manifest, overrides id / name / namespace, delete dupManifest.scope, and since metadata-protocol: duplicatePackage swallows its own installPackage refusal in a bare catch {} — a duplicate whose manifest the registry refuses is created with no package row and no log line #24041 rethrows the install's refusal before any item is copied — the swallowing the cloud seat's note 6236742886 read at cloud's older pin is gone at this base); and importUnmanagedPackage (27908), whose base is built in package-unmanaged-install.ts from the identity dispositions — name, version, type, description, defaultDatasource, dependencies, engines, engine copied; id / namespace / scope not; an undeclared key refused upstream — so a built artifact meets no new refusal there. No in-repo caller sends type: 'application' outside tests (the only source hit is the SBOM generator, unrelated).

  4. Stored rows hydrate; tolerance at the read only — RIGHT. PackageServicePlugin.start (service-package index.ts 673–790) runs the D1 handshake and then registry.installPackage(convertForward(rec.manifest), 'authored'); nothing there judges type / scope, and the diff changes only a comment at that seam. The real-stack pin (ObjectKernel + ObjectQLPlugin + SqlDriver on better-sqlite3 + PackageServicePlugin + the real protocol) publishes a type: 'application', scope: 'environment' row through the service's own publish, restarts, and reads it back as stored with no error line; then a fresh install of the same manifest and a duplicatePackage of the stored app are both refused 400 with nothing written to the registry or sys_packages. No tolerance was added at any write.

  5. Refusal pins carry status AND code — RIGHT. protocol.install-manifest-declaration.test.ts pins resolveThrownHttpError(err) to { status: 400, code: 'VALIDATION_ERROR' } (the real boundary resolver), the key path, the verbatim packageInstallScopeRefusal('cloud') sentence, the id leg answering first, and zero writer calls. The service-package pins and the flipped pin assert err.statusCode === 400 and standardErrorCodeForHttpStatus(err.statusCode) === 'VALIDATION_ERROR' — the code leg there is derived from the status rather than read off the throw; acceptable because the throw carries no .code and the resolver's answer is pinned once in metadata-protocol, and both halves pin the absence of both writes.

  6. No fixture relaxes a rule — RIGHT. All 15 fixture files (7 in metadata-protocol, 8 in objectql) add type: 'app' (plus name where absent) or respell application → app; no schema, default or gate is loosened. The scope: 'environment' left on protocol-package-lifecycle.test.ts's duplicate source is stripped by duplicatePackage itself before the write. The flipped pin in hydration-convert-forward.test.ts changes which gate refuses the assembled HotCRM body (400 at the declaration's strict close, not 422 MisplacedDefaultPageError at the registry) and keeps asserting that nothing is written; the registry's page refusal keeps its pins in that file's hydration half (expectMisplacedDefault, lines 236 and 251) and in objectql's registry-default-record-page.test.ts, so no assertion is lost.

② Semver level

  • .changeset/24086-install-primitive-parses-manifest.md: @objectstack/metadata-protocol: minor, a bang title, Clause-②: no (narrowing), a BREAKING paragraph with FROM → TO and the one-line fix, and the ADR-0087 marker not-required (no-migration-prescription) — a category the gate carries (check-adr-0087-registration.mjs); Check Changeset and Lint & Repo Gates are green on this head.
  • Level RIGHT. A (narrowing) is BREAKING; the launch-window guard (check-changeset-no-major.mjs) ships breaking changes as minor and refuses major, so minor is the only admissible level.
  • Package list RIGHT. The only behavioural source change is metadata-protocol's. service-package's src change is a nine-line comment; objectql's changes are tests only. One package in the frontmatter matches what the diff publishes.
  • Clause-②: no (narrowing) RIGHT. No public export is added, removed or changed (metadata-protocol's index, packages/spec and packages/runtime are untouched; the only new line outside tests is an import); no accept set widens anywhere — the hydration seam's tolerance is behaviour that existed before, now stated, not a new admission; the vitest alias is test infrastructure. The disposition's claim that stored sys_packages rows are untouched and keep hydrating is what the real-stack pin measures.

③ Boundary flags

Report 6237660624 — deviations:

  • d1 (file surface beyond the claim: 8 objectql fixtures, one existing service-package test, a vitest alias) — accepted: the rule's consumer radius; every edit adds the declaration, none relaxes it; the alias is what check:test-source-alias prescribes; no source edit in packages/spec, packages/runtime, or duplicatePackage's reporting.
  • d2 (label-write conflict, zero label writes) — process; no bearing on the diff.
  • d3 (control { id, name } became { id, name, type: 'app' }) — right, see ① item 2: no type default exists and the door refuses a typeless manifest.
  • d4 (refusal prefix Invalid package manifest — is this file's framing) — accepted: each issue's sentence is the declaration's; installBodyRefusal is module-private in @objectstack/runtime, which depends on metadata-protocol, so the dependency direction forbids reuse, and moving it into packages/spec is outside the claim's fence. Not a second rule or a second vocabulary.
  • d5 (gate-runner cap and reruns) — process; the head's check-runs are the verdicts.
  • d6 (one merge of origin/main before the PR opened) — process; origin/main has since moved 17 files past merge base 7d6ec91 (now b1845ed), none overlapping the PR's files or any cited file.

Report 6237660624 — open questions:

Addendum 6237799160 — deviations:

  • a1 (PR body not edited) — owed to the adopting seat before landing, prose only: (1) under "Serial constraint with the producer", name cloud PR gen:schema silently drops PageTabsProps since #2967 — references regen would delete real docs #2978 as the producer half; (2) replace the first Acceptance-notes bullet with the re-entry statement — duplicating a pre-change stored base or app (type: 'application', scope: 'environment', icon) is refused 400 VALIDATION_ERROR naming manifest.type and icon, reported to the caller, writes neither the registry nor sys_packages, pinned in install-primitive-declaration.test.ts; stored rows keep hydrating. The present body is not false (it already says the duplicate is refused 400 naming manifest.type), only superseded in detail, so this is not a FAIL.
  • a2 (a second os-dev-report comment) — asked for by the seat; no bearing.

Cross-lane notes:

  • 6231970586 (domain:spec): followed — the primitive parses PackageInstallManifestSchema, not ManifestSchema; cloud / system are refused with the declaration's sentence verbatim (pinned).
  • 6236742886 (repo:cloud): the ask that the tolerance statement name duplicatePackage as a re-entry path is met in the changeset ("including a duplicatePackage of such a base, is refused") and in the pin's docblock; the hydration-seam comment names "a new write" generically, which is acceptable since the changeset is the shipped text.

Residual, named and not a defect of this diff: the primitive still completes a versionless manifest (#2532) where the door refuses one; pre-existing, documented in the primitive and the changeset's Unchanged bullet, left as is.

Gate verdicts on this head (newest run per name, read at the time of this record): 28 success — Lint & Repo Gates and every Lint · job (ESLint, PM dispatch-gates, docs & code guards, repo & tooling, CI/release/engine, the three scoped lanes), Type Check · workspace / source / consumer / debt ledger and TypeScript Type Check, Build Core, Check Changeset, Check Documentation Links, Flag docs affected by code changes, Governed Surface Queue Guard, Dogfood Regression Gate, filter, the four claim/branch guards (card claims this branch; no other open PR claims the issue; no other open PR claims the single-writer path; part-of PR must not close its card), Test Core 1/6, 5/6 and 6/6; 8 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Dogfood Regression Gate shards, Dogfood Verify CLI, Packed-tarball smoke opt-in, Temporal Conformance) — none a failure; 3 in_progress: Test Core (2/6), Test Core (3/6), Test Core (4/6). No check on this head has concluded failure. The PASS below is the verdict on the diff; the three running shards remain the seat's to re-read green before any landing step.

Implemented-by: claude/issue-24086-install-primitive-parses-manifest
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-17T18:04Z · ACCEPT

Head: a263c0885f1376f32d866f2c106b88bf90187608.

Item Reading
Shape Draft PR → main. The body opens Fixes #24086, its only closing keyword. objectstack-ai/cloud#2948 and its PR objectstack-ai/cloud#2978 are named without one. The seat made the two body edits the dev's addendum 6237799160 asked for; the relay read back identical.
Scope 22 files (+430 / −54): installPackage in packages/metadata-protocol/src/protocol.ts, new pins in metadata-protocol and service-package, a fixture triage across 15 test files (each adds the declaration; none relaxes the rule), one comment at service-package's hydration seam, and one test alias. The service-package files are declared to domain:services on #6021. Not governed (check-governed-merges.mjs --pr 24203); 484 lines.
Premise and ruling The primitive parses the install door's own declaration (PackageInstallManifestSchema), as dispatched. The seat checked the record's central claims on this head before adopting it. The parse at protocol.ts:31429 is PackageInstallManifestSchema.safeParse(manifest). Its parsed copy is never written back (no declaredManifest.data read). duplicatePackage rethrows the refusal. The door hands the primitive the manifest as sent (packages.ts:1228, :1423).
Contract review CONTRACT_REVIEW_TIER, an isolated reviewer: record 6238198640, PASS on this head (narrowing face, every non-prose path).
Prose faces Changeset, sentence by sentence. FROM and TO match the pins (400, VALIDATION_ERROR at the boundary, the key paths, no writes). "Unchanged" matches the code: the version default 0.1.0 and the namespace derivation run as before, and the parse defaults are not stored. "Rows already stored … keep hydrating", and a duplicatePackage of such a base is refused: both are pinned on the real stack.
Pins and ablation Refusals assert status 400 + code VALIDATION_ERROR through resolveThrownHttpError, and the absence of both writes. One ablation of the parse gate turns the refusal pins red in both packages, and the hydration half stays green. The restore is clean.
Clause-② no (narrowing), at a line start in the PR body and the changeset. @objectstack/metadata-protocol minor with a bang (BREAKING under the launch-window convention), and the ADR-0087 marker not-required (no-migration-prescription).
Gates Dev union: 69 derived, 69 run, all exit 0. CI on this head: 47 check-runs, 39 success, 8 skipped, 0 failed. Every skip is rostered in EXPECTED_SKIPS: Auto Label, Check PR Size, Temporal Conformance, Console Pin Gate, Dogfood Verify CLI, the Dogfood matrix template, Build Docs, Packed-tarball smoke.

Disposition:

  • open_questions:
    • [0] A: refuse a typeless manifest. The declaration has no default, and the door refuses one too.
    • [1] A: the refusal stays at the write. Repairing stored rows belongs to the producer half: objectstack-ai/cloud#2948, its PR objectstack-ai/cloud#2978, and the cloud maintainer's decision box named in note 6236742886.
    • [2] A: tracked at the cloud pin bump. The cloud pin that carries this refusal must carry objectstack-ai/cloud#2978's merge; the repo:cloud seat stated it on this card (6236742886).
  • out_of_scope_findings: none.
  • deviations: accepted (the fixture radius; zero label writes under the new os-dev budget; the { id, name, type: 'app' } control; the file's own refusal prefix, since installBodyRefusal is private to @objectstack/runtime, which depends on this package; the gate-runner reruns).
  • Residual, pre-existing and not this card's: the primitive still completes a versionless manifest that the door refuses. It is documented in the primitive and in the changeset's "Unchanged" line.

Landing: ordinary queue (pr_ready + automerge_enable through the relay), in this stroke.


Generated by Claude Code

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

2 participants