Skip to content

feat(spec,cloud-connection,cli): the installModes manifest key and the install door's unmanaged mode (ADR-0131 D6, stage S2) - #22916

Merged
objectstack-fleet[bot] merged 21 commits into
mainfrom
claude/issue-15213-s2-install-modes
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 21 commits into
mainfrom
claude/issue-15213-s2-install-modes

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #15213: stage S2 of the ADR-0131 D6 execution card. The installModes manifest key lands with the install door that honours it, the installMode body key and os package install --install-mode, spelled unmanaged per ruling B (6108509158). Still on the card: S3 (the authoring docs page, which now also carries the packages.ts remedy text, see "Scope and size") and S4 (the objectui marketplace choice).
Clause-②: yes (widening: the optional manifest key installModes; the install-local body field installMode; the CLI option --install-mode; new exports in @objectstack/spec/kernel and @objectstack/metadata-protocol; the error codes STACK_UNMANAGED_INSTALL_UNCOPYABLE and INSTALL_MODE_NOT_PERMITTED)

Held out of the merge queue by #22949. Released 2026-10-12: #22949 is no longer open. main has since been merged twice: at 6326fbe084, with the net diff unchanged, and in the merge round below (5a7ed7ca6), whose one conflict keeps both sides. A fresh contract review of the merged head is owed before the queue.

Claim 6112998649. S1 (importUnmanagedPackage, PR #22879 → 72b26ed4f5) is the verb this door calls. Changeset: .changeset/15213-install-modes.md (minor on @objectstack/spec, @objectstack/cloud-connection, @objectstack/cli, @objectstack/metadata-protocol). Nothing existing is removed. One existing answer is stricter: step 1f (below) refuses a managed install whose namespace a writable base holds with 409 RESOURCE_CONFLICT, where a catalog install of it answered 200.

Merge round (seat order 6121590923; fourth contract review 6121541034)

Head 8a713cd9e: the merge 5a7ed7ca6 of origin/main at e1deaefa8 through scripts/pm/os-regen-merge.sh, then one text-and-comments commit, 8a713cd9e. No force-push, no rebase.

Patch round 2: the editability and stricter-answer wording (seat comment 6117204840, contract review 6117191273)

The review at 618824f3bf failed on two sentences of text; no source logic changed. Commit 57b4f4fa7f:

  • The changeset, sentence 1. "every item is editable" is gone. It now reads: "the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. One exception is carried: an item the package ships with an authored protection lock (_lock) keeps that lock on the copy, and its handling is an open question on feat(spec,objectql,cli): the template install mode — a package copied once into the environment ledger, fully editable, refused on shared-database multi-tenant postures (ADR-0131 D6) #15213." The merge round replaced that last clause.
  • The changeset's close. "behaves exactly as before" is gone. It now reads: "an install that does not name a mode is managed, as before, with one stricter answer". It then names that answer: the 1f refusal, 409 RESOURCE_CONFLICT before anything is written, for a managed install of a package whose namespace a writable base holds, where a catalog install of it answered 200. This body's opening sentence carves out the same answer.
  • The same claim at runtime.
    • The install-local door's unmanaged answer note now reads "The package's metadata is now this environment's own, in the base 'BASE', and it follows no upgrade. Refresh the console to see it."
    • The CLI's unmanaged success line now reads "Package copied into the environment (unmanaged) — its items are this environment's own metadata, and it follows no upgrade".
    • No test asserted the removed wording. The CLI test matches the line's unchanged prefix, so no test changed.
  • Also corrected in this body (the review's boundary flag): the door asks the walled-posture refusal after the payload, id and handshake checks (1a to 1c, none of which writes), not "before anything else about the package".
  • No sync. GitHub reads the PR mergeable: true, state blocked (draft), neither dirty nor behind. origin/main has moved 4 commits past the merge base, and none touches spec source or a generated reference.
  • Verified at 57b4f4fa7f:
    • the dependency closures of @objectstack/cloud-connection and @objectstack/cli were built (exit 0);
    • marketplace-install-local-install-modes.test.ts: 19 of 19;
    • @objectstack/cloud-connection typecheck exit 0;
    • the CLI's package-install-mode.test.ts (--project unit): 8 of 8;
    • @objectstack/cli typecheck exit 0;
    • check-empty-changeset --base origin/main exit 0.
  • Gates for this round's paths. The three files 57b4f4fa7f changes were passed as paths to dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, as the seat ordered; the whole-PR derivation at this head still names the same 124. That gives 61 commands, run at 57b4f4fa7f (recorded before and after the pass): 60 of 61 exit 0. check:dual-build-cjs-loads is NOT MEASURED (a whole-workspace build, excluded by the dispatch). The four check:i18n* / check:lean-entry-closure gates ran after the CLI and the four examples were built. --ran reconciles 61 derived, 60 run, the one UNRUN being dual-build-cjs-loads. The three PR-context guards (check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths) ran against this body with the PR's context, all exit 0.
  • Round 3, comments only (seat comment 6117487130), commit 8e9d1bfce5. The INSTALL_MODES TSDoc (manifest.zod.ts, shipped in the .d.ts) and the door's header docblock now say the copy is the environment's own metadata, which the next save edits, and name the _lock exception in one clause. The door's comments place the walled refusal after the payload check and steps 1b–1c, before any write. Verified at 8e9d1bfce5: check:generated 14 of 14; spec and cloud-connection typecheck exit 0; of the 73 families derived for the two paths, 72 exit 0 and dual-build-cjs-loads is NOT MEASURED.
  • Round 4, text and comments only (seat comment 6118016989, contract review 6117921319), commit 3059dee3f6. "Editable" with no exception beside it is gone from every shipped text the review named: the 1f refusal message, InstallModeSchema's .describe() and the regenerated kernel/manifest.mdx, the CLI flag description and example line, and the changeset heading. It is also gone from the five flagged comments and from importUnmanagedPackage's TSDoc in protocol.ts, which the sweep found. The supersede paragraph now names S1's "editable from then on" and "is fully editable"; S1's entry stays byte-equal. Verified at 3059dee3f6, all exit 0: check:generated 14 of 14, check:docs, check-empty-changeset, and the spec, cloud-connection, CLI and metadata-protocol typecheck. Of the 110 families derived for the round's 8 paths, 109 exit 0 and dual-build-cjs-loads is NOT MEASURED.

Patch round: the sync and the supersede (seat comment 6116200445)

  • The sync. origin/main at 7dbb89dc5d is merged through scripts/pm/os-regen-merge.sh (merge 29cfd19fd6). content/docs/references/index.mdx is generated and not text-merged, so it was rendered again from the merged spec (gen:schema then gen:docs) and committed on its own (d9add8817a). It now reads Automation 71 schemas (main's FlowCollectionSlot), Kernel 158 (this PR's InstallMode), 1,524 in total. This was the red check:docs on Type Check · source gates. After it, check:generated reads 14 of 14 artifacts current, and FlowCollectionSlot is in the same 5 generated files on origin/main and on this head.
  • The supersede (618824f3bf). S1's pending entry .changeset/15213-unmanaged-import-verb.md is restored byte-equal to the merge base (blob af966c215b on both sides). The correction now lives in this PR's own .changeset/15213-install-modes.md. That entry names the earlier one, quotes its two now-false sentences, states what holds now and says it supersedes them. The sentences are "no door calls it yet, and no manifest key declares the mode yet", and seeds and translations "left to the install door, which applies them after the copy as it does for a managed install". This is the mechanism of the fix(spec)!: a single-brace token in a translated flow screen title is refused, like the description beside it (#22627) #22673 changeset and of 19939-flow-collection-value-slots.md. node scripts/check-empty-changeset.mjs --base origin/main exits 0: no changeset from the merge base is modified or deleted.
  • Q2 (installedFrom) and Q3 (seed data): A, as built, per the seat. Q1 (an authored _lock survives the copy): no edit in this round; the seat carries it to the card.

What lands

  • installModes (packages/spec/src/kernel/manifest.zod.ts). An optional, non-empty list of 'managed' / 'unmanaged' (InstallModeSchema, INSTALL_MODES). There is no parse-time default (Q9): absence survives a parse, and resolveInstallModes(manifest) is the one reader of what it means (DEFAULT_INSTALL_MODES, ['managed']). The liveness row is live; its evidence is the door and the authoring refusal.

  • Refused at authoring (Q5). defineStack, and so os validate and os build, refuses a package that permits unmanaged while it carries what the copy cannot write:

    • a non-empty collection of a type the registry declares runtime-uncreatable (allowRuntimeCreate: false, read off DEFAULT_METADATA_TYPE_REGISTRY, never a list);
    • code;
    • an extension of another package's items.

    The code is STACK_UNMANAGED_INSTALL_UNCOPYABLE (422), the eighth site of the StackRefusalError family, with one issues entry per key. The key classification the import verb used to keep privately moved to spec as UNMANAGED_INSTALL_KEY_DISPOSITIONS (kernel/unmanaged-install.ts). The authoring refusal and the door's import now read one table, held total against ManifestSchema and the stack definition by a spec test. So the PR that adds a schema key is the PR that reddens.

  • The door (packages/cloud-connection/src/marketplace-install-local-plugin.ts, POST /api/v1/marketplace/install-local):

    • installMode is parsed against InstallModeSchema. Absent means managed, so every existing caller is unchanged; a value outside the enum answers 400 INVALID_REQUEST before the cloud fetch.
    • Step 1e, on both sources, before any write:
      • a mode the package's installModes does not permit answers 422 INSTALL_MODE_NOT_PERMITTED with details.permitted;
      • a malformed installModes answers PLUGIN_MANIFEST_INVALID, with the id gate's 400/502 split;
      • unmanaged on a walled REQUESTED posture answers 409 UNMANAGED_INSTALL_WALLED_POSTURE (Q2). This is the import verb's own refusal, unmanagedInstallPostureRefusal, now exported. The door asks it after the payload check, step 1b (the id), main's ADR-0131 D3 walled-catalog refusal and step 1c (the handshake), none of which writes, and before any other judgement of the package; the verb still asks it for every other caller. The full merged order is under "Merge round".
    • The unmanaged branch hands the verb ONE flat package body. A compiled artifact's nested manifest: is flattened, the collections winning, as AssembledPackageBodySchema records. The branch runs none of the managed steps: no conflict classification, no D5e gate, no manifest.register, no ledger entry, no syncSchemas, no binder, no applySideEffects. The answer carries installMode, targetPackageId, copiedCount, copied and installedFrom; seeded: { mode: 'skipped' } appears when the package declares seed data. The verb's refusals are relayed with their own code, status, message and details; a 5xx message is withheld and logged.
    • Step 1f (Q6), the managed-after-unmanaged refusal, on BOTH sources before any write. A managed install of a package whose namespace (declared, or derived from its id) is held by a writable base answers 409 RESOURCE_CONFLICT, naming the base in the message and details. A namespace held by another code package keeps the lenient branch's existing behaviour, whose fate is a separate decision.
  • os package install --install-mode managed|unmanaged (packages/cli/src/commands/package/install.ts):

    • Without the option, nothing is sent.
    • An unmanaged answer prints the base, the item count and installedFrom, and none of the managed lines (hot-load; "re-registers on every boot").
    • Asking for unmanaged and getting an answer without it means the runtime predates the mode and installed MANAGED. The command then exits 1 and says so.
    • The command's prose says two sources (catalog, air-gapped), so "mode" has one meaning.
  • The import verb (packages/metadata-protocol/src/package-unmanaged-install.ts), as S1's carry list asks:

    • translations are now copied, each locale of each bundle as one translation item NAMESPACE_LOCALE (crm_zh_cn), the bundles merged in order;
    • installModes is read and never written onto the base;
    • an empty code container (functions: {}) is no code.
    • The MetadataWriteFace docblock is corrected: the verb forwards cause.issues, but its one door relays message + code + details only, so the full-prose default on the 422 stays right.
  • Carried prose.

    • The specVersion guidance names marketplace/template-manifest.zod.ts; the retired cloud/ path is pinned absent.
    • 18.meta-doors-organization-scope-retired.ts:30 reads "an unmanaged install".
    • S1's pending changeset is left byte-equal to the base. This PR's changeset supersedes the two sentences in it that this stage made false: "no door calls it yet", and seeds/translations "left to the install door". See "Patch round".

Zone 2 readings (measured on origin/main at the base 45e206e498)

  1. File list. Every stage-0 S2 site is where the plan put it:

    • ManifestSchema at kernel/manifest.zod.ts:401, with the retired path at :418;
    • defineStack's validators in stack.zod.ts;
    • liveness/manifest.json, which had 0 installModes rows;
    • the door's handleInstall at :930;
    • install.ts:7/10/15/58/98 ("mode" prose);
    • packages.ts:384/421/524 (the "ADR-0005 org overlay" remedies);
    • 18.meta-doors…:30.

    Beyond it, necessarily: S1's package-unmanaged-install.ts (its private key table moved to spec; without a row, installModes would have been refused by the import as an unrecognised key); metadata-protocol/src/index.ts (one export); the two defineStack refusal censuses; and S1's pending changeset.

  2. The door (base 45e206e498):

    • catalog versus air-gapped branch at if (inlineManifest) :969;
    • step 1d at :1149;
    • the managed branch's findConflict at :1195, the D5e gate at :1222, manifest.register at :1272, the ledger write at :1310 and applySideEffects at :1343.

    The unmanaged branch returns before :1195, after 1d, and none of these run (pinned: no register, no sync, no ledger file). The import's three registry reads (protocol.ts:24472 getPackage / manifests.has, :24479 getNamespaceOwners) were measured on the REAL registry by the dogfood suite. See P3.

  3. Translations and installed_from.

    • Translations travel in the copied items. Measured: translation/crm_zh_cn is served after P1 and again after the restart.
    • installed_from is recorded on the door's answer (installedFrom: { packageId, version }) and on the operator log line at info. No durable carrier: it needs a contract decision (the install record or sys_packages.metadata), which is left to the seat. See Acceptance notes.
    • The keys S1 left to the door: translations, now copied by the verb; and data, not loaded, and the answer says so.
  4. The acceptance on examples/app-crm. The fixture is the CRM's built artifact with installModes: ['managed', 'unmanaged'] on its manifest. Suite: packages/qa/dogfood/test/install-local-unmanaged-mode.dogfood.test.ts, 17 cases, green on the merged tree 799c30127b and again at 677d7fad45.

    • P1 (single, one database file across a restart):
      • 200, answering targetPackageId: 'com.example.crm-copy' and installedFrom: { packageId: 'com.example.crm', version: '4.0.0' };
      • every copied item served by GET /meta, never with a non-org provenance;
      • every copied item saved by PUT with its own served body, defineView containers included (view/crm_lead);
      • every row has package_id = the base and organization_id NULL;
      • no ledger file and no registry package under com.example.crm;
      • after the restart, every item is still served, the source is still unregistered, and the base still holds the crm namespace.
    • P2 (isolated):
      • 409 UNMANAGED_INSTALL_WALLED_POSTURE, details: { posture: 'isolated' };
      • zero writes: no base, no sys_metadata row, no ledger file;
      • positive control: the managed install of the same artifact on the same boot answers 200.
    • P3:
      • after the restart, a managed install of 4.0.1 answers 409 RESOURCE_CONFLICT with details: { namespace: 'crm', base: 'com.example.crm-copy' };
      • every copied row's checksum|metadata is byte-identical before and after;
      • a second unmanaged install answers 409 "'com.example.crm-copy' already exists" (getPackage on the real registry);
      • a target that is the booted code package com.example.showcase answers 409 (manifests.has);
      • a second target over the owned namespace answers 409 with details: { namespace: 'crm', owners: ['com.example.crm-copy'] } (getNamespaceOwners).
    • S1's open notes:
      • the container expansion is measured clean (above);
      • the authored protection lock is measured as a gap, under Acceptance notes.
  5. Size. See "Scope and size".

Ablation

Dropping the requested-posture refusal turns P2 red.

  • Mutation. unmanagedInstallPostureRefusal always answers null, via scripts/ablation-replace.mjs in wrap mode (anchor ×1 → ×0, blob 87618e8d12 → c3f5b8c7b9). @objectstack/metadata-protocol was rebuilt, and ablation-dist-preflight found the marker in 2 built files. The build's .d.ts step refused the always-true condition (TS2872), but the JS the dogfood consumes was emitted.
  • Mutated P2: 3 failed, 1 passed (the posture precondition), as predicted:
    • the refusal answered 200;
    • writes happened;
    • the positive control then met the managed-after-unmanaged refusal.
  • Restore: blob 87618e8d12 == HEAD, git diff HEAD empty, rebuild exit 0, ablation-dist-preflight --absent marker absent from all 24 built files, tree clean. P2 green again (4 passed).

Tests (each line names the commit it ran at)

  • This round, at 618824f3bf (after the sync merge 29cfd19fd6), @objectstack/spec:
    • the seven suites this PR touches: 7 files / 205 tests passed (kernel/manifest, kernel/manifest-unknown-keys, kernel/unmanaged-install, stack-unmanaged-install-mode, stack-refusal-envelopes, stack-conversions-record, type-alias-convention.pin);
    • gen:migration-registry current (423 semantic), then --project repo (test:repo): 55 files / 971 tests passed;
    • typecheck exit 0.
    • The merge brought no change to cloud-connection or metadata-protocol source. The lines below are the earlier round's, after the merge 682620e7dc.
  • @objectstack/spec:
    • at bbe1b2be2b (the merged tree): --project local 650 files / 19,405 tests passed (1 todo); --project repo 55 / 971; typecheck exit 0;
    • at 677d7fad45, after the ADR-0122 pin: the four touched suites 106 / 106 (type-alias-convention.pin, kernel/manifest, kernel/unmanaged-install, stack-unmanaged-install-mode); typecheck exit 0.
    • The defineStack refusal censuses (stack-refusal-envelopes, stack-conversions-record) now count eight sites.
  • @objectstack/cloud-connection, full suite at 677d7fad45: 44 files / 536 tests. The new marketplace-install-local-install-modes.test.ts has 19 cases. typecheck exit 0.
  • @objectstack/metadata-protocol at bbe1b2be2b: 5 files / 123 tests (package-unmanaged-install, both face inventories, the lookupArtifactItem call-site census, tenant-authored-write). typecheck exit 0.
  • @objectstack/cli, --project unit, the four package-install-* suites at bbe1b2be2b: 4 files / 23 tests, package-install-mode.test.ts 8 of them. typecheck exit 0 at 677d7fad45. The integration tier is declared to CI: no spawn entry and no integration file is touched.
  • @objectstack/runtime, src/domains/ at bbe1b2be2b: 108 files / 2,712 tests; typecheck exit 0. The runtime diff is empty after the move to S3.
  • Dogfood:
    • the new suite plus the six existing install-local suites at 799c30127b (merged tree, closure rebuilt): 7 files / 56 tests;
    • the new suite again at 677d7fad45 (cloud-connection rebuilt): 17 / 17;
    • typecheck exit 0.

Gates (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, derived again and run at 618824f3bf)

The derivation at 618824f3bf names the same 124 commands as at 677d7fad45. Union at 618824f3bf (git rev-parse --short HEAD, recorded before and after the pass): 122 of 124 exit 0. Reconciled with --ran: 123 of 124 derived families accounted with an exit code; the one UNRUN is dual-build-cjs-loads (below).

  • In the first pass, 8 commands answered exit 3 PREREQUISITE NOT MET, which measures nothing: the worktree was re-created for this round, so the built outputs they read were absent. The 8 are check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift, check:i18n, check:i18n-coverage, check:i18n-walk-parity and check:lean-entry-closure. The packages their own fix lines name were built with their dependencies (69 packages, exit 0, tree clean after), and all 8 were run again at the same head: exit 0. The record above carries the second run.
  • pnpm check:dual-build-cjs-loads: NOT MEASURED. It needs a whole-workspace build, which the dispatch excludes.
  • node scripts/check-empty-changeset.mjs --base origin/main: exit 0. "No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)" and "No changeset from the merge base modified or deleted by this diff". check-changeset-no-major exit 0.
  • pnpm check:platform-checklist: red on origin/main too, not this PR. The same 8 problems (7 ABSENT SYMBOL anchors in docs/qa/platform-checklist/areas/*.json naming protocol.ts / attachment-access-hooks.ts symbols, plus one anchor floor) are identical, line for line, on a detached origin/main at eab7236ff7, measured this round. This PR touches no checklist file and no named symbol.
  • Named by the seat, all exit 0 at 618824f3bf: check:generated, check:docs ("226 generated files in sync"), check:api-surface ("public API surface + factory signatures unchanged"), check:authorable-surface and check-empty-changeset. Also exit 0: check:liveness, check:route-envelope, check:error-code-casing and check:nul-bytes.
  • Fixed in this PR by the first round's own findings: check:slot-lookup, check:spec-parsed-alias, check:test-source-alias and check:empty-state.
  • Artifact rosters (the block the no-path run prints). 42 of its 45 commands ran at 618824f3bf, all exit 0; 14 of those are checker-health --self-test runs, which cannot fail for this diff. The three PR-context guards ran against this body with the PR's context, all exit 0: check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths.
  • After the sync, origin/main moved one commit, eab7236ff7 (feat(verify): the reference-value shape family, runnable as os verify --reference-shapes (#15953 slice 1) #22907). It touches no spec source, no generated reference and no file of this PR, so the reference index stays current against the merge ref. CI reads the current merge ref.

Scope and size

After the merge round, the change set is 2,623 changed lines (+2,451 / −172, the same 40 files), generated included, against the merge base e1deaefa8, under the 3,000 human-merge line. Before that round it was 2,602 changed lines (+2,433 / −169) against the merge base 7dbb89dc5d. It read 2,604 / 41 files before this round, which counted S1's changeset edit that is now restored. It measured 2,667 with the packages.ts remedies, past the stage plan's ~2.6k mark. As the plan prescribes, the packages.ts "ADR-0005 org overlay" remedy text (:421, :524, docblock :384) moves to S3, untouched here. S3 therefore does not fold in.

Acceptance notes

  • Gap, measured, and since ruled (Q1); the changeset names it as the one carried exception to editability. An item a package ships with an authored protection lock (_lock: 'full', ADR-0010) is copied as written. The next PUT /api/v1/meta/view/NAME then answers 403 ITEM_LOCKED, so that copied item is not editable, against D6's "editable in Studio like anything authored there". Measured on the real showcase stack: the import is called directly; the install-local door calls the same verb. The remedy is now ruled: A on [Decision] 「非托管」安装复制带 _lock 的打包项时:去掉锁(A)、拒绝这类包(B),还是原样保留锁(C)?(ADR-0131 D6 × ADR-0010 保护模型) #22971 (6119681934), the import strips _lock / _lockReason / _lockSource and names each stripped lock. A follow-up on feat(spec,objectql,cli): the template install mode — a package copied once into the environment ledger, fully editable, refused on shared-database multi-tenant postures (ADR-0131 D6) #15213 carries the strip. This PR keeps the lock as written today, and every shipped text says so (see "Merge round").
  • installed_from has no durable carrier (see Zone 2 item 3). The seat ruled this A, as built (Q2).
  • Seed data is not loaded by an unmanaged install. The reseed and purge doors act on ledger entries this install never writes, so seeded rows would be rows nothing here could clear. The answer says so. The seat ruled this A, as built (Q3).
  • ADR-0126's status line (docs/adr/0126-packaged-metadata-customization-model.md:3, "template = copied once…") still spells the old word. It is a governed Tier H surface, so it is not edited here.
  • Observation, unmeasured: a managed install of a compiled artifact through this door hands registerApp a body whose namespace sits only on the nested manifest:, so the registry's namespace gate may not see it. The managed-after-unmanaged refusal reads the declared namespace itself, so it does not depend on that.
  • An unrecognised OS_TENANCY_POSTURE is refused by the door's install-capability admission (401 in the unit harness) before step 1e. The import's own suite pins that value as walled.

Generated by Claude Code

claude added 12 commits October 11, 2026 21:26
…nstall door's mode, translations copied

Work in progress for the S2 stage of the unmanaged install mode.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <[email protected]>
…surement leaves the suite

The change set measured past the stage plan's 2.6k-line mark, so the
retired-overlay remedies in runtime/src/domains/packages.ts go to S3, as the
plan prescribes. The locked-item probe measured a real gap (a copied item
that shipped `_lock: 'full'` answers 403 ITEM_LOCKED to the next save); its
remedy is not ruled, so it is reported rather than pinned here.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <[email protected]>
…), pinned isomorphic; the door's objectql lookup carries its contract; the protocol is aliased to source in the door's suites

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 12, 2026
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/cloud-connection, @objectstack/metadata-protocol, @objectstack/spec, touching 61 documentable anchor(s). ⚠️ 10 changed file(s) yielded no anchor (packages/cloud-connection/vitest.config.ts, packages/metadata-protocol/src/index.ts, packages/spec/api-surface/kernel.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

37 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 67baccc5546c795c5b3b77b881c0581fa802b975.

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

What this run could not see
  • 10 changed file(s) yielded no anchor (packages/cloud-connection/vitest.config.ts, packages/metadata-protocol/src/index.ts, packages/spec/api-surface/kernel.json, …) — pages documenting those are invisible to this run
  • 26 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 — 147 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 67baccc5546c795c5b3b77b881c0581fa802b975 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 67baccc5546c795c5b3b77b881c0581fa802b975

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

…tree

The merge of origin/main (7dbb89d) brought FlowCollectionSlot (#22883);
content/docs/references/index.mdx is generated and not text-merged, so it is
rendered again from the merged spec: Automation 71 schemas, total 1524.
Kernel keeps the branch's 158 (InstallMode).

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <[email protected]>
…PR's entry supersedes its two now-false sentences

.changeset/15213-unmanaged-import-verb.md is S1's pending entry on the base,
so it goes back byte-equal to the merge base (7dbb89d). The correction moves
into .changeset/15213-install-modes.md as a supersede: it names that entry,
quotes "no door calls it yet, and no manifest key declares the mode yet" and the
seeds-and-translations clause "left to the install door, which applies them after
the copy as it does for a managed install", and states what holds now. Same
mechanism as #22673's changeset and 19939-flow-collection-value-slots.md.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <[email protected]>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 618824f3bfea4a82293e3d31946b13e45e1f54f1
Local-runs: none

Read-only, adversarial, on PR #22916 (Part of #15213, stage S2) at the head above: the PR body, the net diff against the merge base 7dbb89dc5d (40 files, +2,433 / −169), the head's 42 check-runs, card #15213's body and its 24 comments (the stage plan 6108149738, the stage-0 ACCEPT 6108175106, ruling B 6108509158, the S1 landing 6112255719, the carry list 6112058094, the claim 6112998649, the reports 6116157070 and 6117004605, the seat order 6116200445, the ACCEPT 6117037795 — read for what they assert, not adopted), and on origin/main: ADR-0131 D6 and its spelling note, ADR-0087 D2/D3, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, scripts/pm/check-expected-skips.mjs's roster, and .changeset/pre.json. Nothing built, run or re-run; the expected-skip probe was not needed, the four skips were read off the roster and the workflow conditions.

① Derived judgments

1. The accept set at every door, against the D6 rulings — holds.

  • ManifestSchema.installModes (kernel/manifest.zod.ts): z.array(InstallModeSchema).min(1).optional(), no .default(). The only reader of absence is resolveInstallModes (manifest?.installModes ?? DEFAULT_INSTALL_MODES). Every reader of the key on the head outside tests goes through it: the door's judgeInstallMode (resolveInstallModes({ installModes: declared.data })) and defineStack's validateUnmanagedInstallMode (resolveInstallModes(config.manifest).includes('unmanaged')). No reader restates ['managed']; the two spellings of it in the door are message prose, not reads. The door's step 0 default is for the REQUEST's installMode (absent → managed), a different key, as Q9 and the PR say. Pinned: manifest.test.ts "no parse-time default — an absent key stays absent".
  • defineStack refusal STACK_UNMANAGED_INSTALL_UNCOPYABLE (422, StackRefusalError, eighth site, both censuses raised 7 → 8): fires only when resolveInstallModes(config.manifest) lists unmanaged; refuses (a) a non-empty collection whose type is in RUNTIME_UNCREATABLE_TYPES, derived from DEFAULT_METADATA_TYPE_REGISTRY entries with allowRuntimeCreate: false (never a list), and (b) any non-collection key whose disposition is in UNMANAGED_INSTALL_REFUSING_DISPOSITIONS = {code, uncopyable, envelope}, on the stack and on manifest.*, one finding per key, empty containers skipped (unmanagedInstallValueCarries). That is Q5 A exactly. The one table: UNMANAGED_INSTALL_KEY_DISPOSITIONS (kernel/unmanaged-install.ts) carries S1's 37 keys with the same dispositions, translations moved door → converted, plus installModes: 'door' (38). Totality is pinned both ways in unmanaged-install.test.ts against ManifestSchema's live keys and ObjectStackDefinitionSchema, collections excluded and tombstones excluded. S1's private copy is deleted; the verb's planner now reads the spec table (planUnmanagedImport), so the two refusals cannot disagree.
  • The install-local door, both sources: step 0 parses installMode by reference (InstallModeSchema.safeParse; absent → managed; else 400 INVALID_REQUEST) before any fetch. The inline and catalog branches converge after normalizeBundle into 1a (payload) → 1b (id gate) → 1c (handshake) → 1e judgeInstallMode: a malformed installModes → PLUGIN_MANIFEST_INVALID at the id gate's 400/502 split; a mode not in resolveInstallModes(...) → 422 INSTALL_MODE_NOT_PERMITTED with details.permitted; unmanaged on a walled REQUESTED posture → the verb's own unmanagedInstallPostureRefusal (requestedTenancyPosture() → resolveTenancyPosture(), unrecognised read as walled, 409 UNMANAGED_INSTALL_WALLED_POSTURE, details.posture) — Q2 A. Then 1d (unrunnable code). Then if (installMode === 'unmanaged') return this.installUnmanaged(...) BEFORE 1f, step 2 (findConflict), the D5e gate, manifest.register, the ledger write, syncSchemas, the binder and applySideEffects: installUnmanaged only resolves the protocol, flattens the body, calls importUnmanagedPackage, logs and answers (or relays the verb's code/status/message/details, withholding a 5xx message). Then 1f for managed: findEnvironmentBaseHoldingNamespace (declared namespace or deriveNamespaceFromPackageId; getNamespaceOwners; holder ≠ the package itself; isWritablePackage) → 409 RESOURCE_CONFLICT naming the base — Q6 A, before any write, a code-package holder left to the lenient branch. Pinned in marketplace-install-local-install-modes.test.ts (19 cases: ordering, "registers, ledgers and syncs nothing", both sources for 1f) and in the dogfood suite (P1 no ledger / no registry / rows owned by the new base; P2 zero writes with the managed positive control; P3 refusal before any write with byte-identical rows).
  • The verb's carry items: translations → one translation item per locale named NAMESPACE_LOCALE (unmanagedTranslationItemName, bundles deep-merged in order), ranked into write order; installModes is door → case 'door': break, so the base manifest (...identity) never carries it, pinned in unmanaged-install.test.ts; an empty functions: {} / plugins: [] → continue before the refusal. All three hold.

2. The Clause-② line, through clause2-line.mjs, on the measured diff — the arm stands; see ③ for the one input it makes stricter. The PR body's line 2 Clause-②: yes (widening: the optional manifest key …) matches CLAUSE2_KEY_LINE (key once, no backtick), matchValueToken reads yes, readArmToken reads (widening followed by : → { kind: 'declared', value: 'yes', arm: 'widening' }; the changeset's line reads the same. Removed from source: only UNMANAGED_IMPORT_KEY_DISPOSITIONS / UnmanagedImportKeyDisposition, which @objectstack/metadata-protocol's index.ts never re-exported at the merge base (the package publishes . only), so no published export is removed. The defineStack refusal and 1e bind only a package or request that names the new key or field, which no input accepted at the base could carry (ManifestSchema is strict). Named: the 9 spec exports in the changeset's line are exactly the 9 rows added to api-surface/kernel.json and export-origins/kernel.json; unmanagedInstallPostureRefusal is the one new metadata-protocol index export; the body field, the CLI option and the two codes are named. Levels: four minor (spec, cloud-connection, cli, metadata-protocol) on every grown published package; @objectstack/dogfood is private: true; pre.json is { mode: 'pre', tag: 'next' }; no major. ADR-0087: breakingDeclaration reads no major, no **BREAKING, no bang summary and arm widening → not breaking → no marker owed, none present. Check Changeset is success on the head.

3. The changesets — one sentence FAILS, one is imprecise.

  • .changeset/15213-unmanaged-import-verb.md is blob af966c215b at the merge base, at the head and on origin/main (modified at 677d7fad45, restored at 618824f3bf).
  • The supersede paragraph quotes the earlier entry truly: "no door calls it yet, and no manifest key declares the mode yet" and "left to the install door, which applies them after the copy as it does for a managed install" are byte-for-byte in S1's entry, and what it says holds now is what the diff does (the door calls the verb on installMode: 'unmanaged'; the verb copies translations; nothing loads seed data; the answer carries seeded when seeds are declared).
  • Every other bullet is true to the diff (key, authoring refusal and its fix line, the three door refusals with their codes and status, no ledger / no registration / no seed load, the CLI's silent default and the exit-1 on a runtime that ignored the flag, the copy semantics and the one new export).
  • ⛔ FAIL — the _lock sentence (scope item 6). The entry's second paragraph states: "Unmanaged: its metadata is copied once into this environment under a new writable base, every item is editable, and it follows no upgrade." The PR's own measurement (report 6116157070 finding 1; PR body, Acceptance notes) is that an item shipped with _lock: 'full' is copied as written and the next PUT /api/v1/meta/view/NAME answers 403 ITEM_LOCKED (evaluateLockForWrite, metadata-protection.zod.ts). "Every item is editable" is a universal claim that measurement makes false; the PR body says of the same item "that copied item is not editable". The remedy is not to rule Q1 — it is one sentence: state editability as the environment-authored provenance (_provenance: 'org', the next save edits it) and name the carried exception (an item the package ships with an authored protection lock keeps that lock until Q1 is ruled), or drop "every item is editable". This text ships to consumers as CHANGELOG.md.
  • The closing sentence "Nothing existing is removed or narrowed: … an install that does not name a mode behaves exactly as before" is contradicted by the entry's own 1f bullet, which the same entry declares: a managed install (no mode named) of a package whose namespace a WRITABLE base holds answers 409 RESOURCE_CONFLICT where the cloud-fetched branch answered a lenient 200 with hotLoaded: false. The PR body's "Nothing existing is removed or narrowed" carries the same over-claim. Reword to carve out 1f in the same round.

4. The sync hop 677d7fad45 → 618824f3bf — verified. Among the 40 PR paths plus S1's changeset, git diff --name-status 677d7fad45 refs/pm/pr-22916 -- PATHS lists exactly .changeset/15213-install-modes.md (M), .changeset/15213-unmanaged-import-verb.md (M, the restore to the base blob) and content/docs/references/index.mdx (M, regenerated). The other 217 paths that moved in that range are all in origin/main's delta 3f694715ee..7dbb89dc5d (the merge 29cfd19fd6). Per-path patch hashes of the net diff at 677d7fad45 (against its base) and at the head (against 7dbb89dc5d) agree on 38 of 40 paths; the two that differ are the changeset and the index. The index moved 1523 → 1524 schemas, Kernel 157 → 158 (InstallMode), Automation 71 is main's.

5. Reach — holds. INSTALL_MODE_NOT_PERMITTED is in ERROR_CODE_LEDGER under @objectstack/cloud-connection; STACK_UNMANAGED_INSTALL_UNCOPYABLE under @objectstack/spec beside the seven STACK_* sites; S1's UNMANAGED_INSTALL_* comment now names the door. The generated references carry both (error-code-ledger.mdx, contract.mdx +323 → +325), the key (manifest.mdx, package-api*.mdx, package-registry.mdx, package-upgrade.mdx, authorable-surface kernel/Manifest:installModes, json-schema.manifest and declaration-map kernel/InstallMode), the liveness row live (evidence: the door's judgeInstallMode and defineStack's validateUnmanagedInstallMode) and the state counts 26 → 27 live, 42 → 43 classified; Type Check · source gates (check:generated, check:docs) is success on the head. Callers of POST /api/v1/marketplace/install-local in this repo: the CLI command (sends installMode only when --install-mode is given; package-install-mode.test.ts) and the marketplace UI bundle (sends none). An absent installMode takes the managed path unchanged, pinned by "an absent installMode is a managed install, exactly as before"; packages/runtime/src/domains/packages.ts is not in the diff (the remedies moved to S3). The one managed-path change is 1f, in ③.

6. The open note on _lock. Not in scope to fix; the PR body reports it truthfully as a measured gap with three options. The changeset sentence above is the one FAIL. The same universal claim is also shipped as runtime prose — the door's answer note ("every item is editable and follows no upgrade") and the CLI's success line ("every item is editable, and follows no upgrade") — outside item 6's PR/changeset scope, same one-sentence remedy, listed in ③.

7. CI on the head — green or rostered skips. 42 check-runs: 38 success, 4 skipped, 0 failed, none in progress. The four skips are rows of EXPECTED_SKIPS in scripts/pm/check-expected-skips.mjs: Check PR Size and Auto Label (the pull_request edited run for the body patch, excluded by each job's if:; their synchronize runs on the same head are success), Packed-tarball smoke (opt-in) (no needs:pack-smoke label), Console Pin Gate (filter console output: no .objectui-sha, console script or spec entry-layout path in the diff). All seven required contexts are success, Governed Surface Queue Guard included.

② Semver level

minor on @objectstack/spec, @objectstack/cloud-connection, @objectstack/cli and @objectstack/metadata-protocol, under pre mode next; Clause-②: yes (widening) is the right arm on the measured diff; no major, no ADR-0087 marker owed. The level is right; the FAIL is the entry's text, not its frontmatter.

③ Boundary flags

  • 1f makes one existing input stricter, judged not a narrowing of a published accept set. A managed install of a package whose namespace a writable base holds: the inline branch already threw at the registry's namespace gate; the cloud-fetched branch answered 200 with hotLoaded: false and the register failure in hotLoadError, wrote the ledger, and handed the namespace to code at the next boot. That deferred failure is what the head replaces with 409, as Q6 A orders. A consumer branching on success for that one input sees a refusal where it saw a reported failure. Recorded so the arm's reading is on the record; the changeset's closing sentence should carve it out (item 3).
  • The PR body says the walled refusal is asked "before anything else about the package"; measured, 1a (payload), 1b (id gate) and 1c (handshake) precede 1e on both sources. None writes.
  • Runtime prose carrying "every item is editable": the door's note and the CLI's printSuccess line. Same claim, same remedy as the changeset sentence; not a verdict carrier under item 6.
  • packages/spec/src/api/package-lifecycle.zod.ts:233 (untouched, S1's): "No route serves the verb yet; the install door that reads an installMode calls it." Still true of a dedicated route; the second clause is now present tense. S3 prose.
  • The door's INSTALL_MODE_NOT_PERMITTED message spells "absent, which means ['managed']" in prose beside the resolver read. Prose only.
  • Governed surfaces: none of the 40 paths is under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md or docs/NORTH-STAR.md.
  • Not measured, as the report declares: check:dual-build-cjs-loads (whole-workspace build); Build Core is success on the head.
  • Reviewed-by below is the claim-holding seat's session, adopting this verdict as the brief instructs; the diff was produced by a mode:subagent dev on the branch named in Implemented-by.

What lifts the FAIL: one patch round on .changeset/15213-install-modes.md — rewrite the "every item is editable" sentence to state the measured exception (or drop the universal), and carve 1f out of the closing "behaves exactly as before" (the PR body's "Nothing existing is removed or narrowed" with it). No source change is owed; the runtime strings are a seat's call in the same round. A new record on the next head follows.

Implemented-by: claude/issue-15213-s2-install-modes
Reviewed-by: session_01KNKBCRDJCu5tGy3TEbvtrF

VERDICT: FAIL


Generated by Claude Code

…nment's own metadata, a shipped _lock carried; the one stricter managed answer is named

The contract review on 618824f failed on two sentences, and the seat's
order adds the same claim at runtime:

- .changeset/15213-install-modes.md: "every item is editable" becomes what
  holds — the copy's items are this environment's own metadata and the next
  save edits them — and the carried exception is named: an item shipped with
  an authored protection lock (`_lock`) keeps it on the copy, an open
  question on the card. The close no longer says an install that names no
  mode "behaves exactly as before": it is managed as before, with one
  stricter answer, the 409 RESOURCE_CONFLICT for a managed install whose
  namespace a writable base holds.
- The install-local door's unmanaged answer `note` and the CLI's unmanaged
  success line drop "every item is editable".

Text only; no test asserted the removed wording (the CLI test matches the
success line's unchanged prefix).

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <[email protected]>
… — the copy is the environment's own metadata, a shipped _lock carried; the walled refusal follows 1b-1c

Comments only, per the seat's round-3 order before the second contract
review:

- packages/spec/src/kernel/manifest.zod.ts, the INSTALL_MODES TSDoc (it
  ships in the .d.ts): "editable like anything authored there" becomes the
  environment's own metadata that the next save edits, with the _lock
  exception named in one clause, as the changeset says.
- marketplace-install-local-plugin.ts, the header docblock: the same edit
  for "editable and followed by no upgrade", and the walled refusal's order
  stated as the door has it — after the payload check and steps 1b-1c, none
  of which writes, before any write. The import comment and the 1e step
  comment carried the same "before anything else about the package" claim
  and say the same now.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <[email protected]>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8e9d1bfce5ff5d1cd6971b5cc210a25e301d1759
Local-runs: none

Second contract review of PR #22916 (Part of #15213, stage S2), read-only and adversarial, at the head above. Inputs: the first record 6117191273 (FAIL at 618824f3bf, read for its readings and re-judged, not adopted); card #15213's seat order 6117204840, round-2 report 6117467223, round-3 order 6117487130 and round-3 report 6117767333 (read for what they assert); the PR body; the net diff against the merge base 7dbb89dc5d (40 files, +2,439 / −169); the hop git diff 618824f3bf 8e9d1bfce5 (4 files, +20 / −14, commits 57b4f4fa7f and 8e9d1bfce5); on origin/main scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, .changeset/pre.json and the EXPECTED_SKIPS roster of scripts/pm/check-expected-skips.mjs; the head's 42 check-runs and the failed job's log. Nothing built, run or re-run; the expected-skip probe was not needed, the four skips were read off the roster.

① Derived judgments

1. The first record's two FAIL items are lifted, truly — and the same claim survives in five shipped texts the rounds did not reach: FAIL.

  • Lifted, sentence 1. .changeset/15213-install-modes.md:12 now reads "the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. One exception is carried: an item the package ships with an authored protection lock (_lock) keeps that lock on the copy, and its handling is an open question on feat(spec,objectql,cli): the template install mode — a package copied once into the environment ledger, fully editable, refused on shared-database multi-tenant postures (ADR-0131 D6) #15213." That is what the diff does: the verb copies each item's body as written (package-unmanaged-install.ts, body: body as Record... in enumerateItems), so a shipped _lock travels, and evaluateLockForWrite (metadata-protection.zod.ts) answers ITEM_LOCKED on full with no owner or admin exemption — the measured 403.

  • Lifted, the close. :26 now reads "Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer", and names it: 1f, 409 RESOURCE_CONFLICT before anything is written, "where a catalog install of it answered 200". Verified at the merge base: the catalog branch's register step is lenient — a thrown register sets hotLoadError, the ledger is still written and the answer is 200 (hotLoaded derived from it); only the inline branch answers 422 PLUGIN_REGISTER_FAILED. So the clause is true whether or not registerApp throws for a writable holder; the dev's narrowing from the first record's "200 with hotLoaded: false" is the more exact reading. "Nothing existing is removed" holds for the published surface: the only source removal is UNMANAGED_IMPORT_KEY_DISPOSITIONS / UnmanagedImportKeyDisposition, which @objectstack/metadata-protocol's index never exported at the base; no row leaves api-surface/kernel.json or export-origins/kernel.json.

  • The runtime strings and the TSDoc the rounds reached are true now. The door's note ("The package's metadata is now this environment's own, in the base 'BASE', and it follows no upgrade. Refresh the console to see it."), the CLI's success line ("its items are this environment's own metadata, and it follows no upgrade") and the INSTALL_MODES TSDoc (manifest.zod.ts:401-420, naming the _lock exception) carry no universal. The CLI test pins only the unchanged prefix /Package copied into the environment \(unmanaged\)/; no test pins the note.

  • Every other sentence of the changeset is true to the diff, re-read on this head: the key and its resolver (.min(1).optional(), no .default()); the authoring refusal and its fix line; the three door refusals "before anything is written, on the catalog and the air-gapped source alike" — the two sources converge at 1b (door-head.ts:1197), 1e is :1281-1300, 1d :1302, the unmanaged return :1356, 1f :1365-1393, and the first managed write is step 3's register at :1473; no ledger, no registration, no seed load for unmanaged; the translation items reload at boot (dogfood P1 serves translation/crm_zh_cn after the restart); the CLI's silent default and its exit-1 on a runtime that ignored the flag (package-install-mode.test.ts:135-137); the copy semantics and the one new metadata-protocol export (index.ts, unmanagedInstallPostureRefusal).

  • ⛔ FAIL — the same universal, five more times. The measured fact is that a copied item shipped with _lock: 'full' answers 403 ITEM_LOCKED on its next save. These texts in the diff still assert the copy, or its items, are editable, with no exception beside them:

    1. Runtime output, the 1f refusal message (marketplace-install-local-plugin.ts:1385-1389): "… the environment base 'BASE' — an unmanaged install of this package, or a base authored in this environment — whose items are this environment's own, editable metadata." A per-item universal, the shape the first record failed and the seat's round-2 order ("Drop the universal from both") removed from the other two strings. For a base copied from a package that ships a lock, one of its items is not editable. No test pins this sentence (the suite pins the code, status and details).
    2. A .describe(), InstallModeSchema (manifest.zod.ts:424-425): "unmanaged (copied once into the environment, editable, no upgrade)" — against the managed arm's "sealed". It ships as the whole description of InstallMode in the generated reference content/docs/references/kernel/manifest.mdx (in this diff, "## InstallMode"), where the TSDoc's exception does not accompany it. A locked copied item is exactly "sealed" on the copy.
    3. Runtime output, the CLI flag description (install.ts:104-107, printed by --help): "'unmanaged' (copied once into the environment as editable metadata, no upgrade; …)".
    4. Runtime output, the CLI example line (install.ts:53): "--install-mode unmanaged # copy into the environment, editable".
    5. The changeset's heading (:8, the CHANGELOG.md headline): "copies it into the environment as editable metadata". The body's carve-out follows two paragraphs later; the headline is what a reader greps first.

    The remedy is the one the rounds already applied: say what holds ("the environment's own metadata", "the next save edits it") or name the lock exception, in each of the five; item 2 regenerates manifest.mdx (gen:schema && gen:docs). No logic is owed.

2. The hop 618824f3bf → 8e9d1bfce5 is text and comments only — verified line by line. Four files. .changeset/15213-install-modes.md: two prose paragraphs (:12, :26). packages/cli/src/commands/package/install.ts: one printSuccess string literal (:259). packages/cloud-connection/src/marketplace-install-local-plugin.ts: the header docblock (:54-65), the import comment (:174-177), the 1e step comment (:1281-1283) and the note string literal (:2307-2308). packages/spec/src/kernel/manifest.zod.ts: the INSTALL_MODES TSDoc (:407-412). No condition, branch, accept set, export, .describe(), schema or generated artifact moved: git diff --stat lists no file under api-surface/, export-origins/, declaration-map/, json-schema.manifest/, authorable-surface/, liveness/ or content/docs/, and the TSDoc feeds no .describe(). Type Check · source gates (check:generated, check:docs, check:api-surface) is success on the head; the round-3 report's "gen:schema moved no tracked file" agrees.

3. The rest of the first record holds on this head. Re-read here, not carried: the door's order on both sources (item 1 above, by line); the S1 entry .changeset/15213-unmanaged-import-verb.md is blob af966c215b at the head, at the merge base 7dbb89dc5d and on origin/main — byte-equal; the two supersede quotes are byte-for-byte S1's :10 ("no door calls it yet, and no manifest key declares the mode yet") and :14 ("left to the install door, which applies them after the copy as it does for a managed install"); the Clause-② line on the PR body (:2) and the changeset (:10) both match CLAUSE2_KEY_LINE, value yes, and readArmToken reads (widening ahead of the colon → { value: 'yes', arm: 'widening' }; the levels are four minor (spec, cloud-connection, cli, metadata-protocol) under pre.json { mode: 'pre', tag: 'next' }, no major, and check-changeset-no-major's level axis (yes takes at least minor on a moved published package) is met on all four; the nine spec rows added to api-surface/kernel.json and export-origins/kernel.json are exactly the nine names the changeset's line names; Check Changeset is success. Carried from 6117191273, untouched by the hop: the accept set at ManifestSchema.installModes and the defineStack refusal (RUNTIME_UNCREATABLE_TYPES from the registry, the three refusing dispositions, the 38-row table pinned total), the walled-posture refusal's fail-closed read, the verb's carry items, the reach (ledger entries, generated references, liveness live), and the governed-surface reading — none of the 40 paths is under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md or docs/NORTH-STAR.md.

4. CI on the head: one red, judged not this PR's; everything else green or rostered. 42 check-runs: 37 success, 4 skipped, 1 failure. The four skips are EXPECTED_SKIPS rows: Check PR Size and Auto Label (the pull_request edited run for the body patch, EVENT_REASON; their synchronize runs on the same head are success), Packed-tarball smoke (opt-in) (no needs:pack-smoke label), Console Pin Gate (filter output console false: no .objectui-sha, console script or spec entry-layout path in the diff). The red is the Test Core aggregator (job 114662653458, a required context): all six shards are success; step 8 "Check the per-package suite-duration ceilings" (scripts/check-test-suite-ceilings.mjs) fails on @objectstack/knowledge-ragflow: 4.34 s against its 3.40 s ceiling (1.28×), slowest file src/__tests__/ragflow-adapter.test.ts 0.06 s. Read against the diff: no path of the 40 is under that package; its only dependency on a package this PR moves is @objectstack/spec, and its one test file imports from spec a type only (import type … from '@objectstack/spec/ai'); a suite whose slowest file takes 0.06 s measures the vitest worker's start, not this diff. The same ceiling step tripped on origin/main itself with no PR content, at 1bd612bd68 (job 114663833631, @objectstack/cloud-connection 172.11 s against 163.89 s, 1.05×), while this head's cloud-connection read 0.86× and spec 0.44×. Runner variance on a ceiling generated today, not this PR's — recorded, not failed on. It is still a required context, and the job's step 5 downloads its own run's shard timings, so a re-run of the failed job alone re-reads the same 4.34 s; the fix round's new head re-measures.

② Semver level

minor on @objectstack/spec, @objectstack/cloud-connection, @objectstack/cli and @objectstack/metadata-protocol under pre mode next; Clause-②: yes (widening) is the right arm on the measured diff — nothing published is removed or narrowed, 1f being a stricter answer on an input no base-accepted caller relied on as a success, as the entry now says; no major, no ADR-0087 marker owed. The level is right; the FAIL is text.

③ Boundary flags

  • Source comments carrying the per-item universal, the seat's call for the same round (not runtime output, not a .describe()): the CLI command docblock install.ts:24 ("a new writable base: every item editable, followed by no upgrade") and the door's private installUnmanaged TSDoc :2205-2206 ("every item through the save door a Studio edit takes, organization_id NULL, editable from its first byte"). cloud-connection and cli both ship dist/index.d.ts; the command is not exported from the CLI's index, and the method is private.
  • S1's text, at the base and untouched, carrying the same universal in the same release: .changeset/15213-unmanaged-import-verb.md:10 ("every metadata item is copied once into this environment, is fully editable"), the verb's docblock package-unmanaged-install.ts:12 ("is fully editable"), and the walled-posture sentence the door now relays, :167 ("ONE shared, editable environment ledger" — ledger-level, not per-item). The S2 entry's supersede paragraph names two S1 sentences and closes with "Where the two entries disagree, this one is current", which covers :10 by the blanket; naming that third sentence there is one clause. The S1 entry stays byte-equal (check-empty-changeset), so the supersede paragraph is the only place to say it.
  • package-lifecycle.zod.ts:233 stays S3's prose, as the seat ruled.
  • The INSTALL_MODES TSDoc and the changeset cite #15213; the citation gate (check:issue-citations) and Lint & Repo Gates are success, and the door's own comments carry issue citations already — not a finding.
  • The 1f message's clause also covers "a base authored in this environment": an author may set _lock on an environment-authored item too, so the same word over-claims there independently of the copy.
  • Not measured, as every round declares: check:dual-build-cjs-loads; Build Core is success on the head.
  • Reviewed-by below is the seat's session rendering this verdict; the diff was produced by a mode:subagent dev on the branch in Implemented-by.

What lifts the FAIL: one text round on the five texts in item 1 (the 1f message, InstallModeSchema's .describe() with manifest.mdx regenerated, the CLI flag description and example line, the changeset heading), the two source comments and the supersede clause at the seat's discretion; then a new record on that head, with Test Core re-measured green on it.

Implemented-by: claude/issue-15213-s2-install-modes
Reviewed-by: session_01KNKBCRDJCu5tGy3TEbvtrF

VERDICT: FAIL


Generated by Claude Code

…lls every copied item editable; the S1 entry's two editability phrases are superseded

The second contract review (at 8e9d1bf) found the same per-item
universal, "editable" with no exception beside it, in shipped text the earlier
rounds did not reach. Each now says what holds (the environment's own
metadata, which the next save edits), names the `_lock` exception, or drops
the universal:

- the 1f refusal message (runtime): "whose items are this environment's own
  metadata";
- InstallModeSchema's .describe(), and the generated
  content/docs/references/kernel/manifest.mdx rendered from it;
- the CLI's --install-mode description and its example line;
- the changeset's heading;
- comments: the CLI command docblock, the door's installUnmanaged TSDoc,
  package-unmanaged-install.ts's module docblock and translations note, the
  `converted` disposition's TSDoc, and importUnmanagedPackage's TSDoc in
  protocol.ts (found by the sweep, same claim);
- the changeset's supersede paragraph names S1's "editable from then on" and
  "is fully editable"; S1's entry stays byte-equal to the merge base.

Text and comments only.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <[email protected]>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3059dee3f6a7c1422701ad93690c33400f421bd7
Local-runs: none

Third contract review of PR #22916 (Part of #15213, stage S2 of ADR-0131 D6), read-only and adversarial, at the head above. Inputs: the PR body and its 40-path file list; the net diff against the merge base 7dbb89dc5d (GitHub counts +2,445 / −171 over 40 files; the body's "+2,433 / −169" undercounts it by 14 lines, both under the 3,000 human-merge line); the hop 8e9d1bfce5 → 3059dee3f6 as its own compare diff (8 files, +18 / −14, read line by line); the head's 42 check-runs with the failed job's steps and annotations; the Test Core check-runs of 618824f3bf, 57b4f4fa7f, 8e9d1bfce5, the merge base 7dbb89dc5d and main's 1bd612bd68, all read off the API; card #15213's body and all 33 comments (the two earlier records 6117191273 and 6117921319 read for their readings and re-judged, never adopted; the seat orders and reports read for what they assert); at the merge base, packages/metadata-protocol/src/index.ts and its package.json; at the head, error-code-ledger.zod.ts, the four published manifests, @objectstack/dogfood's manifest and .changeset/pre.json; on the shared checkout, check-expected-skips.mjs's roster, check-test-suite-ceilings.mjs's header with the dataset's ceilings row, and ci.yml's Build Core step list. Nothing built, run or re-run.

① Derived judgments

1. The second record's FAIL is lifted, verified in the hop, and the hop moves no logic — right. git diff 8e9d1bfce5 3059dee3f6 touches .changeset/15213-install-modes.md (the heading, now "copies it once into the environment as the environment's own metadata"; the supersede paragraph, now "supersedes parts of" S1's entry and naming its "editable from then on" / "is fully editable" with what holds), content/docs/references/kernel/manifest.mdx (the regenerated ## InstallMode line), install.ts (the docblock, the example line, the flag description), marketplace-install-local-plugin.ts (the 1f message, now "whose items are this environment's own metadata"; the installUnmanaged TSDoc), package-unmanaged-install.ts (the header and the converted bullet), protocol.ts (importUnmanagedPackage's TSDoc, S1's text, now naming the _lock carry), manifest.zod.ts (InstallModeSchema's .describe(), now "copied once into the environment as its own metadata, no upgrade") and unmanaged-install.ts (the converted comment). Every hunk is a string literal, a .describe() or a comment; no condition, branch, export, schema shape, test or other generated artifact moved, and Type Check · source gates (check:generated, check:docs) is success on the head. The five shipped texts the second record named carry no per-item universal now; where a text speaks of editability it says "the next save edits them" with the _lock exception beside it (the changeset, the INSTALL_MODES TSDoc, the door's header docblock, the verb's header and TSDoc). The "editable" that remain in the 40 files are the supersede clause quoting S1 in order to supersede it, the ledger-level sentences ("ONE shared, editable environment ledger", "one shared editable copy", which is why a walled posture refuses the mode), the generated protocol.mdx rows for the ADR-0010 editable boolean, and the fixture pins whose subjects ship no lock. Right.

2. The accept set at ManifestSchema.installModes — right, and a pure widening. z.array(InstallModeSchema).min(1).optional() with a .describe(), no .default(); InstallModeSchema = z.enum(INSTALL_MODES), INSTALL_MODES = ['managed', 'unmanaged']; InstallMode is z.input (isomorphic, pinned in type-alias-convention.pin.test.ts, 775 → 776); DEFAULT_INSTALL_MODES is frozen ['managed']; resolveInstallModes is the one reader of absence, and the only two non-test readers on the head go through it (the door's judgeInstallMode, defineStack's validateUnmanagedInstallMode). ManifestSchema is strictObject, so at the base a manifest carrying installModes was refused as an unknown key: no input the base accepted gains or loses a meaning here. Pinned in manifest.test.ts: both modes alone and together, an empty list refused with the omit-the-key sentence, template and copy refused, an absent key staying absent after parse.

3. The authoring refusal STACK_UNMANAGED_INSTALL_UNCOPYABLE — right (Q5 A). validateUnmanagedInstallMode returns nothing unless resolveInstallModes(config.manifest) lists unmanaged, so only a package naming the new key can reach it. It judges a non-empty collection by PLURAL_TO_SINGULAR against RUNTIME_UNCREATABLE_TYPES, derived from DEFAULT_METADATA_TYPE_REGISTRY entries with allowRuntimeCreate: false (never a list), and a non-collection key by UNMANAGED_INSTALL_KEY_DISPOSITIONS restricted to UNMANAGED_INSTALL_REFUSING_DISPOSITIONS = {code, uncopyable, envelope}, on the stack's keys and on manifest.*, skipping empty containers through unmanagedInstallValueCarries; one finding per key; 422 in the StackRefusalError family as its eighth site (both censuses 7 → 8). The ledger row sits under @objectstack/spec beside the seven STACK_* sites. Pinned in stack-unmanaged-install-mode.test.ts with a managed-only and a no-key control on every row.

4. One key table, and the removal it replaces — right, nothing published removed. UNMANAGED_INSTALL_KEY_DISPOSITIONS (kernel/unmanaged-install.ts) carries S1's rows with translations moved to converted and installModes added as door; its totality against ManifestSchema's live keys (tombstones excluded) and ObjectStackDefinitionSchema is pinned in spec, and the verb's planner (planUnmanagedImport) reads the spec table, so the authoring refusal and the import cannot disagree. Removed from @objectstack/metadata-protocol: UNMANAGED_IMPORT_KEY_DISPOSITIONS and UnmanagedImportKeyDisposition. Verified at the merge base: src/index.ts (247 lines) carries no line naming them or their module, and package.json publishes exports . only (dist/index.*), so the two names were never a published surface. No row leaves api-surface/kernel.json or export-origins/kernel.json.

5. The install door — right (Q2 A, Q9 A). Step 0 parses installMode by reference (InstallModeSchema.safeParse; absent → managed; else 400 INVALID_REQUEST) before any cloud fetch. The inline and catalog branches converge, and 1e judgeInstallMode runs after the payload, id and handshake checks and before 1d and any write: a malformed installModes → PLUGIN_MANIFEST_INVALID at the id gate's 400 / 502 split; a mode outside resolveInstallModes(declared) → 422 INSTALL_MODE_NOT_PERMITTED with details.permitted (its ledger row is under @objectstack/cloud-connection, the emitter — verified on the head's ledger); unmanaged on a walled REQUESTED posture → the verb's own unmanagedInstallPostureRefusal (409 UNMANAGED_INSTALL_WALLED_POSTURE, details.posture), now exported from the protocol's index. The "absent, which means ['managed']" in the 422 message is prose beside the resolver read, not a second reader. if (installMode === 'unmanaged') return this.installUnmanaged(…) sits before 1f, findConflict, the D5e gate, manifest.register, the ledger write, syncSchemas, the binder and applySideEffects. installUnmanaged asks the protocol for importUnmanagedPackage as a capability question and answers 503 SERVICE_UNAVAILABLE when the verb is absent — never a managed install in its place, which is the right answer under "prefer failing to falling back"; flattens a compiled artifact's nested manifest: beside its collections (collections winning); relays the verb's code, status, message and details, withholding a 5xx message; answers installMode, installedFrom, targetPackageId, namespace, copiedCount, copied, seeded: { mode: 'skipped' } when data carries, and the note. Pinned in the 19 cases of marketplace-install-local-install-modes.test.ts (zero-write evidence on every refusal, both sources for 1f) and in the dogfood suite's P1–P3 on the CRM artifact.

6. Step 1f — a stricter answer on one existing input; judged NOT a narrowing of a published accept set, so the widening arm stands (Q6 A). The install-local body has no spec request schema (the stage-0 census's reading, and this diff adds none), the request is still accepted and parsed, and what 1f adds is a state-dependent conflict refusal in the family of the door's existing findConflict 409s. The input it binds is a managed install of a package whose namespace a WRITABLE base holds (findEnvironmentBaseHoldingNamespace: the declared namespace or deriveNamespaceFromPackageId, getNamespaceOwners, holder ≠ the package, isWritablePackage; base / system / sys excluded; no engine → null; a code-package holder left to the lenient branch). At the base that input was reachable only through a duplicated or authored base, and it never produced a working install: the inline branch was refused at the registry's namespace gate, and the catalog branch answered a lenient 200 with the register failure deferred into hotLoadError and a ledger entry that would fight the base at the next boot — the hazard the stage-0 census recorded. So a 409 before any write replaces a reported failure, not a success a base caller relied on, and the changeset's close now names it as the one stricter answer. Pinned on both sources, with a code-package-holder control.

7. The verb's carry items — right. translations become one translation item per locale named NAMESPACE_LOCALE (unmanagedTranslationItemName, lower-cased, hyphens underscored), the bundles that declare one locale deep-merged in declaration order with the later winning, ranked by the registry's load order, each name checked against METADATA_ITEM_NAME_PATTERN, a non-list translations refused 400 INVALID_REQUEST before any write; the converted disposition refuses whole if the registry ever declares translation runtime-uncreatable; installModes is door, so the base manifest never carries it; functions: {} and plugins: [] carry nothing and refuse nothing. The MetadataWriteFace docblock now states what the thrown value forwards and what the one door relays. Pinned in package-unmanaged-install.test.ts and measured on the real registry by the dogfood suite (P1 serves translation/crm_zh_cn after the restart).

8. The CLI — right. --install-mode takes options: [...INSTALL_MODES] by reference; an absent flag sends no installMode, so an older runtime receives the body it always did; an unmanaged answer prints the base, the item count, installedFrom and the skipped sample data, and none of the managed lines; unmanaged asked for and answered without it exits 1 naming that the runtime installed MANAGED; a refusal prints status and code. Eight cases pinned. @objectstack/cli is published (no private), so its minor is owed and declared.

9. Reach — right. The two codes are in the closed ErrorCode union and in the generated contract.mdx (+323 → +325) and error-code-ledger.mdx; the key is in manifest.mdx, package-api.mdx, package-api-assembled.mdx, protocol.mdx, package-registry.mdx, package-upgrade.mdx, the authorable-surface row kernel/Manifest:installModes, and kernel/InstallMode in json-schema.manifest and declaration-map; index.mdx reads 1,524 schemas, Kernel 158. The nine rows added to api-surface/kernel.json and export-origins/kernel.json are exactly the nine @objectstack/spec names the changeset's Clause-② line names, and unmanagedInstallPostureRefusal is its tenth, the one @objectstack/metadata-protocol index export. The liveness row is live with the door and the authoring refusal as evidence (state counts 26 → 27 live, 42 → 43 classified). 18.meta-doors-organization-scope-retired.ts:30 reads "an unmanaged install" (an entry's reason prose; the registries project, TypeScript Type Check is success). The specVersion guidance names marketplace/template-manifest.zod.ts, pinned to exist.

10. CI on the head — 37 success, 4 skipped, 1 failure; the one red judged not this diff's. The four skips are EXPECTED_SKIPS rows: Check PR Size and Auto Label (the body-edit run's if:; their other runs on this head are success), Packed-tarball smoke (opt-in), Console Pin Gate. The red is the Test Core aggregator (job 114678575109), a required context: all six shards are success; step 8 "Check the per-package suite-duration ceilings" fails on @objectstack/cloud-connection: 195.68s, over its 163.89s ceiling. Slowest file: src/marketplace-install-local-state-machine-exempt.test.ts 3.82s. Read against the diff and the API, not the card's prose: (a) the merge base 7dbb89dc5d has Test Core success, so the same-signature exception does not apply by its letter; (b) this PR's cloud-connection suite and its vitest.config.ts alias have not changed since the first round's head, and the same suite passed the same ceiling on 618824f3bf (check-run 114636727480) and 57b4f4fa7f (114657237317); (c) on 8e9d1bfce5 the same step failed on @objectstack/knowledge-ragflow (114662653458: 4.34s over 3.40s), a package this diff does not touch; (d) main at 1bd612bd68, with no PR content, failed the same step on the same package (114663833631: 172.11s over 163.89s, the same slowest file) while two other Test Core runs on that commit passed; (e) the hop to this head is text and comments only (item 1), and the slowest file named is pre-existing, not this PR's 19-case suite. That is runner variance on a package whose ceiling main itself crosses on some runs — a landing fact, carried to ③, not a contract fact; the ceiling rises only by a ruling (RULED_CEILING_RAISES).

11. Governed surfaces, form, size — right. None of the 40 paths is under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md or docs/NORTH-STAR.md; Governed Surface Queue Guard is success. Head repository equals base repository (not a fork). Draft, no review submitted, no needs:contract-review label to remove. 2,616 changed lines over 40 files, under 3,000.

② Semver level

minor on @objectstack/spec, @objectstack/cloud-connection, @objectstack/cli and @objectstack/metadata-protocol — the four published packages the diff grows (none private; @objectstack/dogfood is private: true and owes nothing) — under .changeset/pre.json { mode: 'pre', tag: 'next' }; Check Changeset is success on both of its runs. Clause-②: yes (widening: …) on the PR body's line 2, on the changeset, and on the seat's claim 6118971938 agree on value and arm, and the changeset's list is the measured surface exactly: the optional key, the ten new exports, the body field, the CLI option, the two codes. Nothing published is removed or narrowed (items 2, 4 and 6): the key lands on a strict schema no base input could carry, the removed metadata-protocol names were unpublished at the base, and 1f is a stricter answer on an input that never produced a success, declared in the entry's close. So yes takes minor, the arm is widening, no (narrowing), not BREAKING, no major, and no ADR-0087 disposition marker is owed — none is present. The entry's heading, its body and its close now state the _lock carry and the one stricter answer; the earlier FAILs on this text are lifted. Level and declaration both right.

③ Boundary flags

  • Test Core is red on this head (①.10) — escalated to the landing seat, not a reason to FAIL the contract. The landing precondition "every check green" is NOT met at 3059dee3f6, and this record clears the contract-review precondition only, never that one. The aggregator's step 5 downloads its own run's shard timings, so re-running the failed job alone re-reads the same 195.68s; a fresh run on this head or a new head re-measures, and a ceiling moves only by a ruling. This PR adds a 19-case suite to a package whose ceiling main already crosses on some runs, so the margin is the ratchet's, not this diff's — the seat's claim 6118971938 names the route it will take if this red is all that is left; that is the seat's call, not re-judged here.
  • Q1, an authored _lock survives the copy — escalated, correctly. Measured by the dev on a showcase boot (403 ITEM_LOCKED on the next save; report 6116157070, finding 1), carried unruled by the ACCEPT 6117037795, and routed to triage by the seat (pm:retriage, 6118956122). Every shipped text now names the carry, so the texts say what the code does today; S3's authoring page waits on the answer, and so does any change to the planner. Not re-measured here.
  • Q2 installed_from — answered A, as built (seat 6116200445): on the answer (installedFrom) and the info log line, no durable carrier; ADR-0131 D6's "recorded for information only" is met, and a reader adds the carrier in the same change.
  • Q3 seed data — answered A, as built: not loaded; the answer says so under seeded when data carries; the reseed and purge doors act on ledger entries this install never writes.
  • Q4 check-empty-changeset — resolved by the supersede: S1's entry is not in this diff (byte-equal to the base by construction) and the S2 entry supersedes its false sentences by quotation; Check Changeset is success.
  • check:dual-build-cjs-loads, "NOT MEASURED" in every dev round — measured in CI: Build Core's step "Every published require entry point actually loads" runs it, and Build Core is success on the head. Closed.
  • package-lifecycle.zod.ts:233 (S1's, untouched): "No route serves the verb yet; the install door that reads an installMode calls it" — the second clause is now present-tense true; S3 prose, as the seat ruled. Carried.
  • ADR-0126's status line ("template = copied once…") still spells the old word; a Tier H surface, rightly not edited here. Carried to S3's governed text.
  • packages/runtime/src/domains/packages.ts "ADR-0005 org overlay" remedies — moved to S3 under the size rule; no packages/runtime path is in the diff, consistent. Carried.
  • Observation, unmeasured (PR body): a managed install of a compiled artifact may hand registerApp a body whose namespace sits only on the nested manifest:. 1f reads declaredPackageManifest(manifest).namespace itself, so it does not depend on that gate; the gate question is pre-existing and outside this diff — a reproduction would be a card, the seat's to file.
  • Unrecognised OS_TENANCY_POSTURE: the door's install-capability admission answers 401 before 1e in the unit harness, so the door's suite cannot reach the fail-closed read; the verb's own suite pins it. Acceptance note stands.
  • The PR body's line count (+2,433 / −169) undercounts GitHub's (+2,445 / −171) by 14; both under the human-merge line. Prose only.
  • Reviewed-by below is the dispatching seat's session, which adopts this verdict as the brief instructs; the diff was produced by a mode:subagent dev on the branch in Implemented-by.

Implemented-by: claude/issue-15213-s2-install-modes
Reviewed-by: session_019G5ZnRrTMiwcHG6iZM19zf

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6326fbe08403c732f901e69397a202d7b45bbfb6
Local-runs: none

Fourth contract review of PR #22916 (Part of #15213, stage S2 of ADR-0131 D6), read-only and adversarial, posted 2026-10-12T09:31Z, at the head above — the merge of main 6a40a09ed9 into 3059dee3f6. Inputs: the PR body and its 40-path file list; the net diff against the merge base 6a40a09ed9 as GitHub serves it (+2,445 / −171 over 40 files), every hunk read, generated pages by their changed lines; the head's check-runs, read twice (2026-10-12T09:20Z: 41 runs, one in progress; 2026-10-12T09:25Z: 42 runs, none in progress); card #15213's body and all 37 comments — the three earlier records 6117191273, 6117921319 and 6119344043 read for their readings and re-judged on this head, never adopted; the rulings 6108509158 (B, unmanaged), 6108175106 (Q2–Q9) and 6119709194 (#22971 A, the _lock strip) read as rulings; the seat orders, reports and the unlock 6120955693 read for what they assert. On the shared checkout, by git show at the merge base: S1's .changeset/15213-unmanaged-import-verb.md, the five package manifests, .changeset/pre.json, metadata-protocol/src/index.ts, meta-spelling/manifest-collection-spelling.ts; on main: scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, scripts/pm/check-expected-skips.mjs, ci.yml. Nothing built, run or re-run. The verdict judges this head's own net diff, not a carry-over from 3059dee3f6.

① Derived judgments

1. The accept set at ManifestSchema.installModes — right, and a pure widening. z.array(InstallModeSchema).min(1).optional() with a .describe(), no .default(); InstallModeSchema = z.enum(INSTALL_MODES), INSTALL_MODES = ['managed', 'unmanaged'] (ruling B); InstallMode is z.input, pinned isomorphic (775 → 776); DEFAULT_INSTALL_MODES frozen ['managed']; resolveInstallModes is the one reader of absence, and both non-test readers on the head go through it (the door's judgeInstallMode, defineStack's validateUnmanagedInstallMode). ManifestSchema is strictObject, so at the base a manifest carrying installModes was refused as an unknown key: no input the base accepted changes meaning. Pinned in manifest.test.ts (each mode alone and both, an empty list refused with the omit-the-key sentence, template and copy refused, an absent key absent after parse). The specVersion guidance now names marketplace/template-manifest.zod.ts, pinned to exist.

2. One key table, and the removal it replaces — right; nothing published removed. UNMANAGED_INSTALL_KEY_DISPOSITIONS (kernel/unmanaged-install.ts, 38 rows) carries S1's rows with translations moved to converted and installModes added as door; UNMANAGED_INSTALL_REFUSING_DISPOSITIONS = {code, uncopyable, envelope}; unmanagedInstallValueCarries reads empty containers as nothing. Totality against ManifestSchema's live keys (tombstones excluded) and ObjectStackDefinitionSchema, both ways, is pinned in spec. The verb's planner now reads the spec table. Removed from @objectstack/metadata-protocol: UNMANAGED_IMPORT_KEY_DISPOSITIONS and UnmanagedImportKeyDisposition — verified at the merge base: src/index.ts names neither them nor their module, and package.json publishes exports . only, so neither was a published surface. No row leaves api-surface/kernel.json or export-origins/kernel.json.

3. The authoring refusal STACK_UNMANAGED_INSTALL_UNCOPYABLE — right (Q5 A). validateUnmanagedInstallMode returns nothing unless resolveInstallModes(config.manifest) lists unmanaged; it judges a non-empty collection by PLURAL_TO_SINGULAR against RUNTIME_UNCREATABLE_TYPES (derived from DEFAULT_METADATA_TYPE_REGISTRY entries with allowRuntimeCreate: false, never a list), and a non-collection key by the shared table restricted to the refusing dispositions, on the stack's keys and on manifest.*, one finding per key, 422 in the StackRefusalError family as its eighth site (both censuses 7 → 8); ledger row under @objectstack/spec. The changeset's five-type list (agents, apis, capabilities, jobs, picklists) is exactly the registry's runtime-uncreatable types that have a collection key: field is uncreatable too, but fields is not a PLURAL_TO_SINGULAR key, so the prose is complete. Pinned in stack-unmanaged-install-mode.test.ts with a managed-only and a no-key control on every row.

4. The install door — right (Q2 A, Q9 A). Step 0 parses installMode by reference (InstallModeSchema.safeParse; absent → managed; else 400 INVALID_REQUEST) before any cloud fetch. Both sources converge, and 1e judgeInstallMode runs after the payload, id and handshake checks and before 1d and any write: a malformed installModes → PLUGIN_MANIFEST_INVALID at the id gate's 400 / 502 split; a mode outside resolveInstallModes(declared) → 422 INSTALL_MODE_NOT_PERMITTED with details.permitted (ledger row under @objectstack/cloud-connection, the emitter); unmanaged on a walled REQUESTED posture → the verb's own unmanagedInstallPostureRefusal (409 UNMANAGED_INSTALL_WALLED_POSTURE, details.posture), now exported from the protocol's index. if (installMode === 'unmanaged') return this.installUnmanaged(…) sits after 1d and before 1f, findConflict, the D5e gate, manifest.register, the ledger write, syncSchemas, the binder and applySideEffects. installUnmanaged asks for importUnmanagedPackage as a capability and answers 503 SERVICE_UNAVAILABLE when it is absent — never a managed install in its place; flattens a compiled artifact's nested manifest: beside its collections (collections winning); relays the verb's code, status, message and details, withholding a 5xx message; answers installMode, installedFrom, targetPackageId, namespace, copiedCount, copied, seeded: { mode: 'skipped' } when data carries, and the note (no hotLoaded, no storageDir). The door's new value imports from @objectstack/metadata-protocol (unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText) ride a dependency cloud-connection/package.json already declares at the base (workspace:*), with the vitest alias added for the suites. Pinned in the 19 cases of marketplace-install-local-install-modes.test.ts (zero-write evidence on every refusal, both sources for 1f, the 503, the relay, the withheld 5xx) and in the dogfood suite's P1–P3 on the CRM artifact.

5. Step 1f — a stricter answer on one existing input; judged NOT a narrowing of a published accept set, so the widening arm stands (Q6 A). The install-local body has no spec request schema (the stage-0 census's reading; this diff adds none), the request is still accepted and parsed, and 1f adds a state-dependent conflict refusal in the family of the door's existing findConflict 409s. It binds a managed install of a package whose namespace a WRITABLE base holds (findEnvironmentBaseHoldingNamespace: the declared namespace or deriveNamespaceFromPackageId; getNamespaceOwners; holder ≠ the package; isWritablePackage; base / system / sys excluded; no engine → null; a code-package holder left to the lenient branch). At the base that input was reachable only through a duplicated or authored base and never produced a working install: the inline branch was refused at the registry's namespace gate, the catalog branch answered a lenient 200 with the register failure deferred and a ledger entry that would fight the base at the next boot. A 409 before any write replaces a reported failure, not a success a base caller relied on, and the changeset's close names it as the one stricter answer. Pinned on both sources with a code-package-holder control, and in dogfood P3 with byte-identical rows across the refusal.

6. The verb's carry items — right. translations become one translation item per locale named NAMESPACE_LOCALE (unmanagedTranslationItemName, lower-cased, hyphens underscored), bundles that declare one locale deep-merged in declaration order, ranked by the registry's load order, each name checked against METADATA_ITEM_NAME_PATTERN, a non-list translations refused 400 INVALID_REQUEST before any write, and the converted disposition refuses whole if the registry ever declares translation runtime-uncreatable; installModes is door, so the base manifest never carries it; functions: {} and plugins: [] carry nothing and refuse nothing. unmanagedTranslationItemName is exported from the module but not from the package index, so it is not a published surface and rightly absent from the Clause-② line. The MetadataWriteFace docblock states what the thrown value forwards and what the one door relays. Pinned in package-unmanaged-install.test.ts; measured on the real registry by dogfood P1 (translation/crm_zh_cn served after the restart, every copied item's PUT 200).

7. The CLI — right. --install-mode takes options: [...INSTALL_MODES] by reference; an absent flag sends no installMode; an unmanaged answer prints the base, the item count, installedFrom and the skipped sample data and none of the managed lines; unmanaged asked for and answered without it exits 1 naming that the runtime installed MANAGED (no capability advertised that the runtime did not deliver); a refusal prints status and code; "two sources" prose. Eight cases pinned. @objectstack/cli is published, so its minor is owed and declared.

8. Reach — right. The two codes are in the closed ErrorCode union and in the generated contract.mdx (+323 → +325) and error-code-ledger.mdx; the key is in manifest.mdx (with the regenerated ## InstallMode page), package-api.mdx, package-api-assembled.mdx, protocol.mdx, package-registry.mdx, package-upgrade.mdx, the authorable-surface row kernel/Manifest:installModes, and kernel/InstallMode in json-schema.manifest and declaration-map; index.mdx reads 1,524 schemas, Kernel 158. The nine rows added to api-surface/kernel.json and export-origins/kernel.json are exactly the nine @objectstack/spec names the changeset's Clause-② line names; unmanagedInstallPostureRefusal is its tenth, the one @objectstack/metadata-protocol index export. The liveness row is live with the door and the authoring refusal as evidence (state counts 26 → 27 live, 42 → 43 classified). 18.meta-doors-organization-scope-retired.ts:30 reads "an unmanaged install". Type Check · source gates (check:generated, check:docs, check:api-surface) is success on this head. No content/docs/releases/ page and no CHANGELOG.md is touched.

9. The shipped texts — right on this head. The five texts the second record failed, and the five the seat swept, carry no per-item universal: where a text speaks of editability it says the copy is the environment's own metadata and the next save edits it, with the _lock exception beside it (the changeset's body and supersede paragraph, InstallModeSchema's .describe() and its manifest.mdx line, the INSTALL_MODES TSDoc, the door's header docblock and installUnmanaged TSDoc, the 1f message, the CLI docblock, flag description and example line, the verb's header, importUnmanagedPackage's TSDoc). The supersede paragraph quotes S1's entry truly: "no door calls it yet, and no manifest key declares the mode yet", "left to the install door, which applies them after the copy as it does for a managed install", "editable from then on" and "is fully editable" are byte-for-byte in the entry at the merge base, and S1's entry is not in this diff. What these texts still say about the lock's STATUS is in ③.

10. CI on the head — all green or rostered skips, read twice. At the first read one shard (Test Core (3/6)) was in progress; at the second, 42 check-runs: 38 success, 4 skipped, 0 failed, 0 in progress. The four skips are EXPECTED_SKIPS rows: Check PR Size and Auto Label (the edited-event run of the body patch; their synchronize runs on this head are success), Packed-tarball smoke (opt-in) (no needs:pack-smoke label), Console Pin Gate (filter console output: no .objectui-sha, console script or spec entry-layout path in the diff). All seven required contexts are success, Test Core's aggregator included — the per-package ceiling step that was red on 3059dee3f6 is green here, after #22973 landed and #22949 closed.

11. Governed surfaces, form, size, fork — right. None of the 40 paths is under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md or docs/NORTH-STAR.md; Governed Surface Queue Guard is success. Head repository equals base repository. Draft; no review submitted; no needs:contract-review or skip-changeset label. Line 1 Part of #15213, no closing keyword (Part-of PR must not also close its card and The card this PR closes must claim this branch both success). 2,616 changed lines over 40 files, under 3,000.

② Semver level

minor on @objectstack/spec, @objectstack/cloud-connection, @objectstack/cli and @objectstack/metadata-protocol — the four published packages the diff grows (none private at the merge base; @objectstack/dogfood is private: true and owes nothing) — under .changeset/pre.json { mode: 'pre', tag: 'next' }; Check Changeset is success on both of its runs on this head. The Clause-② line on the PR body (line 2), on the changeset (line 10) and on the seat's claim 6118971938 agree: CLAUSE2_KEY_LINE matches, the value reads yes, and readArmToken reads (widening ahead of the colon as the fixed arm, so the declaration is { value: 'yes', arm: 'widening' }. The changeset's list is the measured surface exactly: the optional key, the ten new exports (items 2 and 8), the body field, the CLI option, the two codes. Nothing published is removed or narrowed (items 1, 2 and 5): the key lands on a strict schema no base input could carry, the removed metadata-protocol names were unpublished at the base, and 1f is a stricter answer on an input that never produced a success, declared in the entry's close. So yes takes minor (check-changeset-no-major's level axis is met on all four), the arm is widening, no (narrowing), not BREAKING, no major, and no ADR-0087 disposition marker is owed — none is present. Level and declaration both right.

③ Boundary flags

  • This head is dirty against the current main — escalated to the landing seat; it is a landing fact, not a contract fault, and it bounds what this record covers. main is 5 commits past the merge base 6a40a09ed9, and three of them move paths this PR edits: 147a0d1acc (finding: a stack's functions array accepts two entries authoring one name, and objectstack build exits 0 shipping the second under a suffixed name (on main, not callable after load), where composeStacks already refuses a duplicate function name #22902 / PR feat(spec)!: refuse a duplicate name inside one stack’s functions array (#22902) #22931) on packages/spec/src/stack.zod.ts; 4806b18aea (refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under single and refused under a wall (ADR-0131 D2/D3/D5/D13) #15204 W2 / PR fix(security)!: under a wall a catalog-carrying package is the platform operator's — install-local and the /packages delete refuse tenants (ADR-0131 D3) #22935) on packages/cloud-connection/src/marketplace-install-local-plugin.ts (+155 / −3) and packages/cloud-connection/vitest.config.ts (+9, an alias inserted at the same spot as this PR's). GitHub reads the PR mergeable: false, mergeable_state: dirty, so the next head is a conflict-resolving merge through os-regen-merge.sh, which is not a pure regeneration: this record does not carry over to it under contract-review.md's regeneration exception, and a fresh at-tier record on the merged head is owed before the queue. Read for that record, from the two main patches: (a) 147a0d1acc adds collectDuplicateFunctionNameErrors INSIDE the existing cross-reference aggregate (STACK_CROSS_REFERENCE_INVALID), so it mints no ninth StackRefusalError code and the 7 → 8 censuses in this PR hold by reading — the merged tree's spec suites decide; (b) 4806b18aea inserts ADR-0131 D3's refuseWalledCatalogPackage (403 FORBIDDEN for a non-operator caller under a wall when the package carries a position, permission set or capability) right after 1b and before 1c, so on the merged door it precedes this PR's 1e — both are pre-write refusals, and for an unmanaged install of a catalog-carrying package by a tenant admin under a wall the answer becomes the D3 403 rather than the 409 UNMANAGED_INSTALL_WALLED_POSTURE; the dogfood P2 caller is the seeded platform admin, whom D3 admits, so P2's expectations read as unchanged, but that is the merged head's CI to prove; (c) the same commit adds @objectstack/objectql to cloud-connection/package.json and pnpm-lock.yaml, and moves packages/runtime/src/domains/packages.ts (+79), the file S3's remedies target. The merge hop's own Regen-provenance: line applies only to regenerated paths, not to these hand-resolved ones.
  • Q1, an authored _lock survives the copy — answered by the maintainer, and this head's texts are one clause stale on its STATUS. Ruling A on [Decision] 「非托管」安装复制带 _lock 的打包项时:去掉锁(A)、拒绝这类包(B),还是原样保留锁(C)?(ADR-0131 D6 × ADR-0010 保护模型) #22971 (6119709194, 2026-10-12T07:25Z, 「同意」): the import strips _lock / _lockReason / _lockSource from every copied item and the result names each stripped lock with the vendor's reason; carrier "this track's S3 or the S2 follow-up, whichever lands first", so S2 landing without the strip is inside the ruling's own plan. On this head (made after that ruling) the copy still keeps the lock, and every shipped text says so truthfully; what five of them add — "its handling is an open question on feat(spec,objectql,cli): the template install mode — a package copied once into the environment ledger, fully editable, refused on shared-database multi-tenant postures (ADR-0131 D6) #15213" — is no longer true: it is ruled, not open, and dispatched. The clause ships in the published .d.ts (INSTALL_MODES TSDoc), in the CHANGELOG entry (15213-install-modes.md), the door's header docblock, the verb's header and importUnmanagedPackage's TSDoc. Not a verdict carrier: the behaviour it describes is this head's behaviour, and the ruling names the follow-up as the carrier of the change. Since a new head is owed anyway (the merge above), the one-clause rewrite — "ruled A on [Decision] 「非托管」安装复制带 _lock 的打包项时:去掉锁(A)、拒绝这类包(B),还是原样保留锁(C)?(ADR-0131 D6 × ADR-0010 保护模型) #22971: a follow-up strips the lock" — rides that hop at no extra round; the next record reads it, and the follow-up's own changeset states the strip when it lands.
  • Q2 installed_from — answered A, as built (seat 6116200445): on the answer (installedFrom) and the info log line, no durable carrier; D6's "recorded for information only" is met.
  • Q3 seed data — answered A, as built: not loaded; the answer says so under seeded when data carries; the reseed and purge doors act on ledger entries this install never writes.
  • Q4 check-empty-changeset — resolved by the supersede: S1's entry is not in this diff and the S2 entry supersedes its false sentences by quotation; Check Changeset is success.
  • Ordering, prose only: 1d (code this door cannot run) precedes the unmanaged branch, so a package that permits unmanaged and ships a handler-only job is refused by 1d's 422 VALIDATION_ERROR rather than the import's UNMANAGED_INSTALL_UNCOPYABLE; both before any write, and defineStack already refuses that package at authoring. The INSTALL_MODE_NOT_PERMITTED message's "absent, which means ['managed']" is prose beside the resolver read, not a second reader.
  • check:dual-build-cjs-loads, "NOT MEASURED" in every dev round — measured in CI: Build Core's step "Every published require entry point actually loads" runs it (ci.yml), and Build Core is success on this head. Closed.
  • Carried to S3, as the seat ruled: package-lifecycle.zod.ts:233 ("No route serves the verb yet; …"), ADR-0126's status line (Tier H, "template = copied once…"), and the packages.ts "ADR-0005 org overlay" remedies — S3 rebases those onto main's 4806b18aea change to the same file.
  • Observation, unmeasured (PR body): a managed install of a compiled artifact may hand registerApp a body whose namespace sits only on the nested manifest:; 1f reads declaredPackageManifest(manifest).namespace itself, so it does not depend on that gate. Pre-existing, outside this diff; a reproduction would be a card.
  • Unrecognised OS_TENANCY_POSTURE: the door's capability admission answers 401 before 1e in the unit harness; the verb's own suite pins the fail-closed read. Acceptance note stands.
  • The PR body's "+2,433 / −169, 2,602 lines" undercounts GitHub's +2,445 / −171 by 14; both under the human-merge line. Prose only.
  • Reviewed-by below is the dispatching seat's session, which adopts this verdict as the brief instructs; the diff was produced by a mode:subagent dev on the branch in Implemented-by, and the head's merge commit by the seat's relay.

Implemented-by: claude/issue-15213-s2-install-modes
Reviewed-by: session_019G5ZnRrTMiwcHG6iZM19zf

VERDICT: PASS

…-install-modes

One textual conflict, packages/cloud-connection/vitest.config.ts: both
sides inserted an anchored source alias at the same spot. Both kept — the
metadata-protocol alias (this branch, ADR-0131 D6) and the objectql alias
(main, ADR-0131 D3 W2).

packages/spec/src/stack.zod.ts, the install-local door and
packages/metadata-protocol/src/protocol.ts merged without a conflict:
both duplicate-name and unmanaged-install collectors are present, and the
D3 walled-catalog refusal sits where main put it, after step 1b and
before step 1c, so ahead of step 1e.

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <[email protected]>
…atus clause is ruled, not open; the door's comments name D3 in the merged order

Five shipped texts now say one thing about a lock an item ships with: the
copy keeps it as written today, and a follow-up on #15213 strips it per
#22971's ruling A. Two of them said its handling was an open question;
three said only that the lock is kept. The INSTALL_MODES TSDoc, the
changeset entry, the install-local door's header docblock, the verb's
module header and importUnmanagedPackage's TSDoc.

The merge placed main's ADR-0131 D3 walled-catalog refusal after step 1b
and before step 1c, so ahead of step 1e. The door's header docblock and
the 1e comment now list it among the checks that precede the mode
judgement. No behaviour changes.

Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf
Co-authored-by: Claude <[email protected]>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8a713cd9e66ceb5744467f978d88dc07b99d1c1d
Local-runs: none

Fifth contract review of PR #22916 (Part of #15213, stage S2 of ADR-0131 D6), read-only and adversarial, posted 2026-10-12T10:53Z, at the head above: the merge 5a7ed7ca6 of main e1deaefa8 into 6326fbe084 through os-regen-merge.sh, then one text-and-comments commit. A conflict-resolving merge is not a pure regeneration, so the fourth record does not carry over and this head is judged on its own net diff. Inputs: the PR body and its 40-path file list; the net diff against the merge base e1deaefa8 as GitHub serves it (+2,451 / −172 over 40 files), every hunk read, generated pages by their changed lines; the head's 42 check-runs, read twice (at the first read one Check Changeset run, the edited-event run that followed the seat's body edit, was still in progress; at the second, none); card #15213's body and all 39 comments — the four earlier records 6117191273, 6117921319, 6119344043 and 6121541034 read for their readings and re-judged here, never adopted; the rulings 6108509158 (B, unmanaged), 6108175106 (Q2–Q9) and 6119709194 (#22971 A, the _lock strip) read as rulings; the seat orders and the dev reports, the merge-round report 6122613472 included, read for what they assert and checked against the diff. By REST GET on this repository: at the head, the install door's whole source, the five package manifests, .changeset/pre.json, error-code-ledger.zod.ts and manifest-collection-spelling.ts; at the merge base, metadata-protocol/src/index.ts; on main, scripts/pm/check-expected-skips.mjs; on the shared checkout, scripts/pm/clause2-line.mjs. Nothing built, run or re-run; no worktree.

① Derived judgments

1. The accept set at ManifestSchema.installModes — right, a pure widening. z.array(InstallModeSchema).min(1).optional() with a .describe() and no .default(); InstallModeSchema = z.enum(INSTALL_MODES), INSTALL_MODES = ['managed', 'unmanaged'] (ruling B); InstallMode is z.input and pinned isomorphic (775 → 776); DEFAULT_INSTALL_MODES is frozen ['managed']; resolveInstallModes is the one reader of absence, and the only two non-test readers in the diff go through it — the door's judgeInstallMode (resolveInstallModes({ installModes: declared.data })) and defineStack's validateUnmanagedInstallMode (resolveInstallModes(config.manifest)); the import's planner classifies the key door and never reads its value. ManifestSchema is strictObject, so at the base a manifest carrying installModes was refused as an unknown key: no input the base accepted changes meaning. Pinned in manifest.test.ts (each mode alone and both, an empty list refused with the omit-the-key sentence, template and copy refused, an absent key absent after parse, the resolver on undefined / null / {}). The specVersion guidance now names marketplace/template-manifest.zod.ts, pinned to exist.

2. One key table, and the removal it replaces — right; nothing published is removed. UNMANAGED_INSTALL_KEY_DISPOSITIONS (kernel/unmanaged-install.ts) carries S1's rows unchanged — compared row by row against the block the diff deletes from package-unmanaged-install.ts — with translations moved door → converted and installModes added as door; UNMANAGED_INSTALL_REFUSING_DISPOSITIONS is the set {code, uncopyable, envelope}; unmanagedInstallValueCarries reads an absent, null or empty value as nothing. Totality against ManifestSchema's live keys (tombstones excluded) and ObjectStackDefinitionSchema, both directions, is pinned in spec, and the verb's planner reads the spec table, so the authoring refusal and the import cannot disagree about a key. Removed from @objectstack/metadata-protocol: the module-level UNMANAGED_IMPORT_KEY_DISPOSITIONS and UnmanagedImportKeyDisposition. Verified at the merge base by REST: src/index.ts names neither, and package.json publishes exports . only, so neither was a published surface and no deep import reaches the module. No row leaves api-surface/kernel.json or export-origins/kernel.json.

3. The authoring refusal STACK_UNMANAGED_INSTALL_UNCOPYABLE — right (Q5 A). validateUnmanagedInstallMode returns nothing unless resolveInstallModes(config.manifest) lists unmanaged. It judges a non-empty collection by PLURAL_TO_SINGULAR against RUNTIME_UNCREATABLE_TYPES, derived from DEFAULT_METADATA_TYPE_REGISTRY entries with allowRuntimeCreate: false (never a list), and a non-collection key by the shared table restricted to the refusing dispositions — on the stack's own keys and again on manifest.*, one finding per key, empty containers skipped. The eighth site of the StackRefusalError family, 422, both censuses raised 7 → 8; the ledger row sits under @objectstack/spec beside the seven STACK_* sites (verified on the head's ledger). The changeset's five-type list (agents, apis, capabilities, jobs, picklists) is exactly the registry's runtime-uncreatable types that have a PLURAL_TO_SINGULAR key: field is uncreatable too, and fields is not a key of that map (verified at the head), so the prose is complete. Pinned in stack-unmanaged-install-mode.test.ts with a managed-only and a no-key control on every row.

4. The install door on the merged head — right (Q2 A, Q9 A), and the order is read off the head's source, not the body. Step 0 parses installMode by reference (InstallModeSchema.safeParse; absent → managed; anything else, null included, → 400 INVALID_REQUEST) before any cloud fetch. The inline and catalog branches converge, and then, by line in marketplace-install-local-plugin.ts at this head: 1a the payload check (PLUGIN_MANIFEST_INVALID, 400 / 502), 1b the package id (same split), main's ADR-0131 D3 refuseWalledCatalogPackage (403), 1c the protocol handshake (422 OS_PROTOCOL_INCOMPATIBLE), 1e judgeInstallMode, 1d unrunnable code (422 VALIDATION_ERROR), the unmanaged return, 1f, then 2 findConflict and 3 manifest.register — the first write. So both of this PR's refusals and its branch sit before any write on both sources, exactly as the PR body's merge-round list states. 1e: a malformed installModes → PLUGIN_MANIFEST_INVALID at the id gate's 400 / 502 split; a mode outside resolveInstallModes(declared) → 422 INSTALL_MODE_NOT_PERMITTED with details.permitted (its ledger row is under @objectstack/cloud-connection, the emitter — verified on the head's ledger); unmanaged on a walled REQUESTED posture → the verb's own unmanagedInstallPostureRefusal (409 UNMANAGED_INSTALL_WALLED_POSTURE, details.posture), now exported from the protocol's index. installUnmanaged asks for importUnmanagedPackage as a capability and answers 503 SERVICE_UNAVAILABLE when it is absent — never a managed install in its place; flattens a compiled artifact's nested manifest: beside its collections, the collections winning; hands the verb one flat body with the installer as actor; relays the verb's code, status, message and details, withholding a 5xx message and logging it; answers installMode, installedFrom, targetPackageId, namespace, copiedCount, copied, seeded: { mode: 'skipped' } when data carries, and the note — no hotLoaded, no storageDir, no ledger entry, no register, no syncSchemas, no binder, no applySideEffects. The door's new value imports from @objectstack/metadata-protocol ride a dependency cloud-connection/package.json declares at the head (workspace:*). Pinned in the 19 cases of marketplace-install-local-install-modes.test.ts (zero-write evidence on every refusal, both sources for 1f, the 503, the relay, the withheld 5xx, the nested-manifest read) and in the dogfood suite's P1–P3 on the CRM artifact.

5. Step 1f — a stricter answer on one existing input; judged NOT a narrowing of a published accept set, so the widening arm stands (Q6 A). The install-local body has no spec request schema and this diff adds none; the request is still accepted and parsed; 1f is a state-dependent conflict refusal in the family of the door's existing findConflict 409s. It binds a managed install of a package whose namespace a WRITABLE base holds (findEnvironmentBaseHoldingNamespace: the declared namespace or deriveNamespaceFromPackageId; getNamespaceOwners; holder ≠ the package; isWritablePackage; base / system / sys excluded; no engine → null; a code-package holder left to the lenient branch). At the base that input never produced a working install — the inline branch was refused at the registry's namespace gate and the catalog branch answered a lenient 200 with the register failure deferred into hotLoadError and a ledger entry that would fight the base at the next boot. A 409 before any write replaces a reported failure, not a success any base caller relied on, and the changeset's close names it as the one stricter answer. Pinned on both sources with a code-package-holder control and in dogfood P3 with byte-identical rows across the refusal.

6. The verb's carry items — right. translations become one translation item per locale named NAMESPACE_LOCALE (unmanagedTranslationItemName: lower-cased, hyphens underscored), bundles that declare one locale deep-merged in declaration order with the later winning, ranked by the registry's load order, each name checked against METADATA_ITEM_NAME_PATTERN, a non-list translations refused 400 INVALID_REQUEST before any write, and the converted disposition refuses whole if the registry ever declares translation runtime-uncreatable; installModes is door, so the base manifest never carries it; functions: {} and plugins: [] carry nothing and refuse nothing (the carries-check runs before the refusing switch). unmanagedTranslationItemName is exported from the module but not from the package index, so it is not a published surface and rightly absent from the Clause-② line. The MetadataWriteFace docblock now states what the thrown value forwards and what the one door relays. Pinned in package-unmanaged-install.test.ts; measured on the real registry by dogfood P1 (translation/crm_zh_cn served after the restart, every copied item's PUT 200).

7. The CLI — right. --install-mode takes options: [...INSTALL_MODES] by reference, no default; an absent flag sends no installMode, so an older runtime receives the body it always did; an unmanaged answer prints the base, the item count, installedFrom and the skipped sample data, and none of the managed lines; unmanaged asked for and answered without it exits 1 naming that the runtime installed MANAGED, so no capability is advertised that the runtime did not deliver; a refusal prints status and code; the "two sources" prose. Eight cases pinned. @objectstack/cli is published, so its minor is owed and declared.

8. Reach — right. The two codes are in the closed ErrorCode union and in the generated contract.mdx (+323 → +325) and error-code-ledger.mdx; the key is in manifest.mdx (with the regenerated InstallMode section), package-api.mdx, package-api-assembled.mdx, protocol.mdx, package-registry.mdx, package-upgrade.mdx, the authorable-surface row kernel/Manifest:installModes, and kernel/InstallMode in json-schema.manifest and declaration-map; index.mdx reads 1,524 schemas, Kernel 158. The nine rows added to api-surface/kernel.json and export-origins/kernel.json are exactly the nine @objectstack/spec names the changeset's Clause-② line names (INSTALL_MODES, InstallModeSchema, InstallMode, DEFAULT_INSTALL_MODES, resolveInstallModes, UNMANAGED_INSTALL_KEY_DISPOSITIONS, UNMANAGED_INSTALL_REFUSING_DISPOSITIONS, UnmanagedInstallKeyDisposition, unmanagedInstallValueCarries), and unmanagedInstallPostureRefusal is its tenth, the one @objectstack/metadata-protocol index export. The liveness row is live with the door's judgeInstallMode and defineStack's validateUnmanagedInstallMode as evidence, both present in the diff (state counts 26 → 27 live, 42 → 43 classified; Spec property liveness is success). 18.meta-doors-organization-scope-retired.ts reads "an unmanaged install". Type Check · source gates (check:generated, check:docs, check:api-surface) is success on this head. No content/docs/releases/ page and no CHANGELOG.md is touched; S1's .changeset/15213-unmanaged-import-verb.md is not among the 40 paths, so it is byte-equal to the merge base by construction.

9. The shipped texts after the merge round — right. No added line in the diff carries "open question" (swept). The five texts the seat order 6121590923 named carry the one clause — the lock is kept on the copy as written today, and a follow-up on #15213 strips it per #22971's ruling A — in the changeset entry, the INSTALL_MODES TSDoc (shipped in the .d.ts), the door's header docblock, the verb's module header and importUnmanagedPackage's TSDoc. That is what this head's code does: the planner copies each item's body as written, so a shipped _lock travels and the next save of that one item answers ITEM_LOCKED; the ruling names the follow-up as the carrier of the strip, and the strip is not in this diff, as ordered. The "editable" that remain on added lines are the supersede clause quoting S1 in order to supersede it, one door comment about "an editable shared copy" (ledger-level, the reason a wall refuses the mode), and the dogfood suite's "editable shown by a write" on the CRM artifact, which ships no lock. The supersede paragraph quotes S1's entry truly ("no door calls it yet, and no manifest key declares the mode yet"; "left to the install door, which applies them after the copy as it does for a managed install"; "editable from then on"; "is fully editable"), and what it says holds now is what the diff does.

10. The merge round itself — verified as the PR body describes it, on the diff. The two door comments the dev edited beyond the five ordered texts — the header docblock's ordering sentence and the 1e comment — now name D3 among the checks that precede the mode check, which item 4 reads as true on this head; both are comments with no behaviour beside them. The one textual conflict, cloud-connection/vitest.config.ts, keeps both source aliases (this PR's @objectstack/metadata-protocol first, main's @objectstack/objectql after it, visible as the hunk's trailing context). stack.zod.ts holds both collectors: main's duplicate-function-name collector sits inside the cross-reference aggregate (it mints no ninth STACK_* code), beside this PR's validateUnmanagedInstallMode, so the 7 → 8 censuses hold — Test Core is success on this head. No merge=os-regen artifact moved on both sides; check:generated is inside Type Check · source gates, success.

11. CI on the head — all green or rostered skips, read twice. At the second read, 42 check-runs: 38 success, 4 skipped, 0 failed, 0 in progress — both Check Changeset runs success. The four skips are EXPECTED_SKIPS rows of check-expected-skips.mjs on main: Check PR Size and Auto Label (the edited-event run after the seat's body edit — EVENT_REASON; their earlier runs on this head are success), Packed-tarball smoke (opt-in) (no needs:pack-smoke label on the PR), Console Pin Gate (filter output console false: none of the 40 paths is .objectui-sha, a console script, packages/spec/package.json or packages/spec/tsup.config.ts). All seven required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. GitHub reads the PR mergeable: true, mergeable_state: clean at the second read (unstable at the first, with the one run in progress).

12. Governed surfaces, form, size, fork — right. None of the 40 paths is under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md or docs/NORTH-STAR.md; Governed Surface Queue Guard is success. Head repository equals base repository. Draft; no review submitted; labels documentation, size/xl, tests, tooling — no needs:contract-review, no skip-changeset. Line 1 Part of #15213, no closing keyword (Part-of PR must not also close its card and The card this PR closes must claim this branch both success on both of their runs; the newest Claim: on the card, 6118971938, names this branch). 2,623 changed lines over 40 files, under 3,000.

② Semver level

minor on @objectstack/spec, @objectstack/cloud-connection, @objectstack/cli and @objectstack/metadata-protocol — the four published packages the diff grows (none carries private at the head; @objectstack/dogfood is private: true and owes nothing) — under .changeset/pre.json { mode: 'pre', tag: 'next' }; Check Changeset is success on both of its runs on this head. The Clause-② line on the PR body (line 2) and on the changeset (line 10) both match CLAUSE2_KEY_LINE, the value reads yes, and readArmToken reads (widening ahead of the colon as the fixed arm, so the declaration is { value: 'yes', arm: 'widening' } on both, and it agrees with the seat's claim 6118971938. The changeset's list is the measured surface exactly: the optional key, the ten new exports (items 2 and 8), the body field, the CLI option, the two codes. Nothing published is removed or narrowed (items 1, 2 and 5): the key lands on a strict schema no base input could carry, the removed metadata-protocol names were unpublished at the base, and 1f is a stricter answer on an input that never produced a success, declared in the entry's close. So yes takes minor (check-changeset-no-major's level axis is met on all four), the arm is widening, no (narrowing), not BREAKING, no major, and no ADR-0087 disposition marker is owed — none is present. Level and declaration both right; the entry's heading, body and close state the _lock carry with its ruled follow-up and the one stricter answer.

③ Boundary flags

  • The merge-round report's deviations (6122613472), each answered. (a) The PR body was not written by the dev; the body on the PR now carries the merge-round section (the conflicts, the merged door order, the five texts before and after) and the unchanged Clause-② line — written by the seat, which is what the edited-event check-runs on this head record; read as current. Closed. (b) The two door comments edited beyond the order — comments only, verified in the diff (item 10). Closed. (c) Commit trailers — not a contract fact. (d) check:platform-checklist red on main — a maintainer-run corpus gate absent from this head's 42 check-runs, touching no path of this PR; carrier none, as the report says. (e) origin/main moved on after the merge — the PR reads mergeable: true, clean; the queue rebuilds the merged generation, which is the landing seat's ordinary path, not a contract fact.
  • No open_questions in the merge-round report. The earlier rounds' four, as they stand on this head: Q1 (an authored _lock survives the copy) — ruled A on [Decision] 「非托管」安装复制带 _lock 的打包项时:去掉锁(A)、拒绝这类包(B),还是原样保留锁(C)?(ADR-0131 D6 × ADR-0010 保护模型) #22971 (6119709194); the five shipped texts now state the kept lock and the ruled follow-up, and the strip is rightly not in this diff; the follow-up's own changeset states the strip when it lands. Q2 installed_from — A, as built: on the answer (installedFrom) and the info log line, no durable carrier; D6's "recorded for information only" is met. Q3 seed data — A, as built: not loaded; seeded says so when data carries; the reseed and purge doors act on ledger entries this install never writes. Q4 check-empty-changeset — resolved by the supersede: S1's entry is not in the diff and the S2 entry supersedes its false sentences by quotation.
  • D3 ahead of 1e on the merged head — prose, and main's rule. For a caller below the operator rung under a wall, on a package that carries a position, permission set or capability, main's D3 answers 403 before this PR's 1e would answer 409 UNMANAGED_INSTALL_WALLED_POSTURE; both are pre-write refusals, the PR body states the merged order and the one answer that changes, and the changeset's 409 sentence reads as the mode check's own answer. Dogfood P2's caller is the seeded platform admin, whom D3 admits, and Dogfood Regression Gate is success on this head.
  • Two new refusals the changeset does not enumerate — prose only. 400 INVALID_REQUEST for an installMode outside the enum (a value of the NEW field), and 503 SERVICE_UNAVAILABLE when the runtime's protocol lacks importUnmanagedPackage (a runtime whose @objectstack/metadata-protocol predates S1 — a dependency this PR's cloud-connection manifest declares). Neither binds an input the base accepted; both are pinned in the door's suite.
  • Tracker numbers in shipped text. The ordered _lock clause cites #15213 and #22971 in the INSTALL_MODES TSDoc (shipped in the .d.ts) and in the CHANGELOG entry. Lint & Repo Gates (check:doc-authoring, check:issue-citations) is success on this head, so the gates that govern the spelling admit it; recorded, not failed on — the seat's call.
  • Prose beside by-reference reads: the step-0 refusal spells "'managed' or 'unmanaged'" and the INSTALL_MODE_NOT_PERMITTED message spells "absent, which means ['managed']", each beside the by-reference parse and the resolver read — not second readers.
  • Not measured locally by the dev, measured in CI: check:dual-build-cjs-loads runs in Build Core and check:type-check-debt in Type Check · debt ledger, both success on this head. Closed.
  • A seat-state fact, not a contract fact: the PR's assignee is zhuangjianguo (set at pr_create) while the card's assignee is os-warren since claim 6118971938. Set by another actor's act; the landing seat's to read, never corrected here.
  • Carried to S3 and S4, as ruled: package-lifecycle.zod.ts:233 ("No route serves the verb yet; …"), ADR-0126's status line (Tier H, "template = copied once…"), the packages.ts "ADR-0005 org overlay" remedies (S3 rebases onto main's own change to that file), and the objectui marketplace choice (S4).
  • Observation, unmeasured (PR body): a managed install of a compiled artifact may hand registerApp a body whose namespace sits only on the nested manifest:; 1f reads declaredPackageManifest(manifest).namespace itself, so it does not depend on that gate. Pre-existing, outside this diff; a reproduction would be a card.
  • Unrecognised OS_TENANCY_POSTURE: the door's capability admission answers 401 before 1e in the unit harness; the verb's own suite pins the fail-closed read. Acceptance note stands.
  • Reviewed-by below is the dispatching seat's session, which adopts this verdict as the brief instructs; the diff was produced by mode:subagent devs on the branch in Implemented-by, and the head's merge commit by the seat's merge-round dev.

Implemented-by: claude/issue-15213-s2-install-modes
Reviewed-by: session_019G5ZnRrTMiwcHG6iZM19zf

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 12, 2026 10:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit c7d6037 Oct 12, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15213-s2-install-modes branch October 12, 2026 11:29
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants