Repository navigation
feat(metadata-protocol,spec): importUnmanagedPackage, the unmanaged install mode's copy-once import (ADR-0131 D6, stage S1) - #22879
Conversation
…sponse schemas, two refusal codes Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…geset Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…-unmanaged-import-verb
…ions; pin the handshake refusal Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…s engine double Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 44 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b86685a45fea3a7ca64b311ca84553bc70210e6b && git checkout b86685a45fea3a7ca64b311ca84553bc70210e6b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d952358a278e081648bb8fca1385ec4c16b07047 d15160d1087c0c96478872177e061f4d8998487e && git checkout -B drift-repro d952358a278e081648bb8fca1385ec4c16b07047 && git merge --no-ff d15160d1087c0c96478872177e061f4d8998487e
node scripts/docs-audit/affected-docs.mjs --json d952358a278e081648bb8fca1385ec4c16b07047
|
Contract reviewServed-tier: Inputs: card #15213 (body and all 17 comments, the S1 report ① Derived judgmentsPublished request schema — right. Published response schema — right. Two error codes — right, statuses right. The verb's refusal set and order — right, a superset of the plan, every addition pre-base. As built: (1) organization-scoped request; (2) the published declaration (400); (3) the REQUESTED posture through Pins read in the test file, each built against the REAL The ruled spelling The new Q8 — a published verb with no door is the ruled verb-first order, not a declared-but-unenforced capability. Three grounds. Everything this diff declares is enforced by the thing that declares it: the request schema is parsed by the verb, the response is the verb's return, the codes are thrown by the verb, and the verb performs the import end to end. No author-facing declaration ships — no Generated shards and the gate ledger — right. ② Semver levelWhat the diff publishes. The changeset. The ③ Boundary flagsFrom the S1 open_questions.
deviations.
out_of_scope_findings — the seat's carry list, each judged.
This review's own carries, none a defect.
Check-runs on Implemented-by: VERDICT: PASS Adopted by |
Part of #15213. This is stage S1 only, the import verb behind the
unmanagedinstall mode; the card stays open for S2 (theinstallModesmanifest key with its install door,--install-modeand theinstallModebody key), S3 (the authoring docs page) and S4 (the objectui marketplace choice).Clause-②: yes (widening)
The widening: two new published schemas in
@objectstack/spec/api(ImportUnmanagedPackageRequestSchema, parsed by its producer, andImportUnmanagedPackageResponseSchema), and two new error codes registered to@objectstack/metadata-protocol. Nothing existing is removed or narrowed. Changeset:.changeset/15213-unmanaged-import-verb.md(minoron both packages).What it does
ObjectStackProtocolImplementation.importUnmanagedPackage({ manifest, targetPackageId?, targetName? })(packages/metadata-protocol/src/protocol.ts, besideduplicatePackage) copies every metadata item of ONE package body into a NEW writable base in the environment ledger, throughsaveMetaItem, the save door a Studio edit takes. Every copied row is owned by the base, carriesorganization_idNULL, is served_provenance: 'org', and the next save edits it. Nothing is registered under the source id. This is the part of a managed install that the manifest service does, and nothing more.The rulings it carries, as built:
unmanaged(ruling 6108509158, letter B): the codes areUNMANAGED_INSTALL_*and the write face is'package-unmanaged-import'.resolveTenancyPosture(); an unrecognised value is read as walled (fail-closed, as the protocol's other posture gate does).group/isolatedanswerUNMANAGED_INSTALL_WALLED_POSTURE/ 409, anddetails.posturenames it.targetPackageIddefaults to the source id plus-copy(the Studio duplicate default). The source namespace is kept. The base is installed scope-less (writable) throughinstallPackage, never best-effort. A target id naming an installed package is refused (RESOURCE_CONFLICT/ 409): never into an existing base.isRuntimeCreateAllowed, registry-only);OS_METADATA_WRITABLEis not consulted, and a test sets it and shows it does not openjob. Code keys (functions,onEnable,plugins,devPlugins,runtimeModule, and a plugin artifact'smain/runtime/packaging/integrity), extensions of another package's items (objectExtensions,picklistExtensions,navigationContributions,contributes) and the artifact envelope (packages, a nestedmanifest) are refused whole withUNMANAGED_INSTALL_UNCOPYABLE/ 422, naming each key. The authoring half (defineStack/os validate) rides S2 with the manifest key.INVALID_METADATA/ 422,FLOW_CONVERSION_CONFLICT/ 409), and collisions with environment rows or items a managed package ships (RESOURCE_CONFLICT/ 409,details.collisions). A write that fails afterwards runsdeletePackage(base), which removes its rows and drops its tables, and the thrown error keeps the failed item's owncodeandstatus, names the item, and says what the rollback did (details.rolledBack). A rollback that cannot finish is logged once aterrorwith the consequence and the fix.What is copied, and what is left to the door
The pure half lives in
packages/metadata-protocol/src/package-unmanaged-install.ts. Copied: the items of every manifest collection (PLURAL_TO_SINGULAR, the one map the artifact loader and the authoring lint read) plus the assembledviewItems, written in registryloadOrder, a view container named by the object it binds (the artifact loader's naming). Every other top-level key is classified inUNMANAGED_IMPORT_KEY_DISPOSITIONS, and a test holds that table total againstManifestSchema(tombstoned keys excepted) and the stack definition in both directions, so a key either schema gains reddens a test instead of being refused with no reason on record. An unclassified key is refused, never skipped.Two classes are deliberately not this verb's, because a managed install leaves them out of the manifest service too: seed
dataandtranslationsare side effects install-local's step 5 applies in either mode (seeds are data, never metadata items), and deployment configuration (i18n,requires,datasourceMapping, …) is read by a stack's boot, by no install. S2's door owns their unmanaged fate; see the acceptance notes.Measured before building (the dispatch's mechanism assumptions)
external-importis NOT this import's face: it serves@objectstack/service-datasource's "Import as Object" route, which relays400 EXTERNAL_IMPORT_ERROR. One new face was needed, as the stage plan said, and both face switches state it (destructiveChangeRemedygets its own 409 clause;specValidationFindingskeeps the full-prose default, documented).DuplicatePackageRequest). The request schema here is new, and ENFORCED rather than transcribed: the verb parses through it. The module header ofpackage-lifecycle.zod.tssays so.installModes/installMode: still zero hits inpackages/andexamples/; nothing of S2 was needed, and noruntimefile is edited.Tests
packages/metadata-protocol/src/package-unmanaged-install.test.ts, 40 cases. The verb runs against a stub engine that keeps rows, so the REALsaveMetaItem,installPackageanddeletePackagerun; its write verbs route through the producer's dispatch predicates, and itsfindhonourslimit. Refusals are asserted as the enveloperesolveThrownHttpErroranswers (codeandstatus), never a bare throw. The stage-0 pins, each built:package_id= the base andorganization_idNULL, nothing under the source id; the response parses throughImportUnmanagedPackageResponseSchema;_provenance: 'org'(a view's registry item; an object's registered schema, under the base id);isolated,group, an unrecognised value) is refused 409 with ZERO writes and no package; the lit control onsingleimports;INVALID_METADATA/ 422 and leaves no package, no row, and the object's table dropped;Ablations (one-shot, not kept as tests)
Each leg mutated through
scripts/ablation-replace.mjs(the anchor must hit exactly once, the write is verified on disk by blob hash, the restore is proven againstHEADby blob hash and an emptygit diff HEAD), inside a wrapper carrying an EXIT/INT/TERM restore trap. The suite importssrcdirectly (nodistleg). Direction predicted before running: red for all four; observed red for all four, and only where predicted. Run atb5cfa28ee0; the two mutated files are byte-identical at this PR's head (protocol.tsblob432f1d7841,package-unmanaged-install.tsblob352e1b7afc).throwremoveddeletePackagecall replaced by an empty outcomeGates (local, targeted; CI runs the full farm)
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatd15160d108: 122 commands, a strict superset of the 84 derived at dispatch (the additions come from the generated docs pages and the gate ledger this diff touches).d15160d108, each exit code captured to disk before any pipe: 122 × exit 0.--ranreconciliation over the recorded codes: 122 derived, 122 run, 0 NOT-MEASURED, 0 UNRUN.b5cfa28ee0found two real reds, both in this PR's test double, both fixed ind15160d108:check:objectql-double-limit(the stub'sfindignoredlimit) andcheck:engine-double-contract(a new pinned double needs its ledger rows, written by the gate's own--write: 3 rows added, 0 lost). Three gates that run read a missingdistthere (exit 3, not measured); after building theobjectqlandclient-reactclosures all three measured green above.@objectstack/metadata-protocol:typecheck(tsc --noEmit) green atd15160d108; the full suite at the post-merge tree2a26dc7eec: 225 files passed, 3 skipped, 28,168 tests passed, with one red file, thelookupArtifactItem(call-site census, fixed inb5cfa28ee0(the new call removed) and re-run green with this PR's suite and both face inventories (4 files, 100 tests).@objectstack/spec: the fulltypecheckchain (tsc --noEmit, scripts, test-typecheck) green; every suite undersrc/api/(51 files, 1,595 tests) green;check:generatedreports all 14 generated artifacts current after the merge.pnpm lint, the full test farm, and the merge queue's rebuild. The derivation flags its tree as behindorigin/mainby at least 5 commits, two of which changed gate scripts (scripts/check-route-envelope.mjs,scripts/cross-package-test-inputs.mjs); CI reads the merged state.Size: 1,566 changed lines (1,553 added, 13 deleted, generated included), 17 files.
Acceptance notes
Out of scope for S1, recorded for the carrier named; none is filed.
dataandtranslationsto the door, as install-local's step 5 applies them for a managed install. One difference S2 has to decide on: a managed install rehydrates its translation bundles from its ledger entry at each boot; an unmanaged install writes no ledger entry, so bundles loaded once into the i18n service would not come back after a restart unless S2 carries them some other way. Carrier: S2.installed_fromhas no carrier yet. The card body asks for an informationalinstalled_from; the stage-0 report proposed the server-stated write source. The verb does not state asourceon itssaveMetaItemcalls, because the save door reads any statedsourceas a stored-row rewrite and skips the authored-write rule for it. The answer names the source id and version, which the door can record. Carrier: S2.getMetadataTypeSchemabefore the base. Carrier: the seat, at review.handlerand nobodyare not judged by the verb: install-local's step 1d refuses them before any mode fork, using the runtime binder this package cannot import. Carrier: S2 (install-local runs 1d before any point where S2 can branch on the mode).shipped by a managed package: naming it would add alookupArtifactItem(call site, whichprotocol.lookup-artifact-item-call-sites.test.tsrecords with a disposition, in a file outside this stage's surface. The namespace refusal does name its owners. Carrier: none.protectionlock is copied as written, so it may stay locked in the environment ledger; and an exported package whoseviewItemscarry an edited view-container expansion may be refused by the save door's container-expansion predicate on import (then rolled back). Carrier: S2's acceptance run onexamples/app-crm.convertStoredItem's legacy-shape notice says "stored row … re-save it" when the import converts an artifact item; the wording describes a stored row, not an import. Carrier: none.Generated by Claude Code