Skip to content

feat(cloud-connection,metadata-protocol): an unmanaged copy can take the package's sample data, owned by the installer - #31398

Draft
objectstack-fleet[bot] wants to merge 9 commits into
mainfrom
claude/project-thread-b11gkm
Draft

objectstack-fleet[bot] wants to merge 9 commits into
mainfrom
claude/project-thread-b11gkm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #30808

Clause-②: yes (widening) — the install-local door accepts an optional seedSampleData beside installMode: 'unmanaged', and the importUnmanagedPackage verb takes a door-stated seedData; the published ImportUnmanagedPackageRequestSchema is unchanged
Metadata-protocol: none

维护者速读

  1. 非托管安装新增可选开关 seedSampleData,默认关闭;关闭时行为与之前完全一致。
  2. 打开后,示例数据以 seed 元数据项写入新的 base(即“种子回执”,存于环境元数据中),再由安装门以安装者身份一次性加载,owner_id 与 created_by 均为安装者;原包里不同角色拥有的行会合并为同一个拥有者。
  3. 清除(purge)读取同一回执,按 externalId 精确删除复制的行,用户自建的行保留;回执读不到时返回 503,不删任何行。
  4. 重复安装仍返回 409;行间查找按加载顺序解析;targetNamespace 只改 object 名,记录与 externalId 原样保留。
  5. 重启不会重新插入任何行(复制后、清除后都已用真实启动验证);回执项再次保存或发布,与任何环境 seed 项一样按 externalId 匹配、不重复加载。
  6. 无 spec 改动;前提更正:种子可携带文件字节(files),框架本身没有记录配额;卸载前需先清除,否则行会留下。

What changes

The switch (@objectstack/cloud-connection, POST /marketplace/install-local). seedSampleData is accepted beside installMode: 'unmanaged' and is off by default. Without it, an unmanaged copy loads no row and answers the same seeded: { mode: 'skipped', … } as before. With true, the door loads the copy's datasets once, inline, and answers seeded: { mode: 'inline', inserted, updated, skipped, errors }. A value that is not a boolean, or one sent with a managed install (which always loads its data), answers 400 INVALID_REQUEST before anything is written.

Ownership. Every copied row is owned by the person who installed it: the door loads with the installer as the load's user and stamps owner_id and created_by with that id (the #30324 option C / #30680 answer A rule). Rows the package wrote as different personas collapse to that one owner; the copy keeps no record of the persona a row had.

The seed receipt (@objectstack/metadata-protocol, importUnmanagedPackage). Asked with the door-stated seedData: true (read off before the strict request parse, like actor), the verb writes each data dataset as a seed item of the new base, in the environment's own sys_metadata, after every other copied item: <object>_seed, then <object>_seed_2 for a second dataset of the same object. This receipt is what the door loads from and what the purge deletes by. Under targetNamespace only the dataset's object follows the #30794 rename map; records and externalId are copied verbatim, because the rename's free-text rewrite would otherwise reach record values (a probe showed a record value 'crm_account' rewritten to 'mycrm_account'), and a changed key would break both lookups and the purge. A dataset that cannot be written (no object, no records array, a name past the storage limit) is refused with 400 before any write; a seed item the save door refuses rolls the whole copy back, as every copied item does; seed not runtime-creatable on the target is a finding on the key data. A re-run is still refused with 409.

Two receipt designs were weighed. A marker column on every seeded row would need a schema change on every object the package seeds, and a row the person later edits would still carry it. The receipt in the environment ledger needs no column, survives restarts with the base, and lets the purge run the same externalId match the managed purge already uses. The receipt was chosen.

Reading the receipt. Each receipt item is read through the protocol's by-name read (getMetaItem), scoped to the base. The list read cannot serve it: a seed body declares no name (SeedSchema is closed), and the list keys every stored item by its body's name, so getMetaItems({ type: 'seed' }) serves no stored seed item at all (measured on the real showcase boot: 5 active rows in sys_metadata, 0 listed, each one served by name). On install the names come from the import's own answer (copied); on purge they come from the base's active seed rows in sys_metadata, read as the system for their names only. An item that is not served, or is not a dataset, fails the whole read.

The purge (POST /marketplace/install-local/:id/purge-sample-data). When the install ledger has no entry for the id, the purge now checks for an installed, writable base under that id, reads its seed items, and deletes exactly the rows they name by externalId; a row the person wrote in the same object survives. A receipt that cannot be read answers 503 SERVICE_UNAVAILABLE and deletes nothing: an unread receipt is never taken for "no sample data". An unknown id still answers 404. The managed path is unchanged (the shared body moved into purgeSeedDatasets).

No double load. A boot never applies the receipt: a restart after the copy, or after the purge, inserts no row. A copied seed item saved later writes metadata only, and drafted and published it behaves as every environment seed item does: create-only, matched by externalId, so the rows already loaded are matched and none is inserted twice.

Tests

  • packages/metadata-protocol/src/package-unmanaged-install.test.ts, describe "the sample data, copied when the door asks — the seed receipt (An unmanaged install can copy the package's seed data, removable as one batch, owned by the installer (gallery demos need data) #30808)", 11 cases: off by default; planned after every other item, named <object>_seed, records verbatim; under targetNamespace only object renamed, records and externalId verbatim; _seed_2 naming; a dataset missing records or object answers 400 with no writes; seed not runtime-creatable is a finding on data; the verb writes the seed rows into the new base with no organization_id and the response parses against ImportUnmanagedPackageResponseSchema; a refused seed item rolls back the whole copy with failedItem: { type: 'seed', name: 'crm_lead_seed' }; a re-run answers 409 with no writes; an undeclared key (withSampleData) answers 400; a copied seed item re-saved, then drafted and published, inserts no row (matched: ['a1', 'a2'], inserted: 0).
  • packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts, describe "install-local — an unmanaged copy's sample data, asked for with seedSampleData (An unmanaged install can copy the package's seed data, removable as one batch, owned by the installer (gallery demos need data) #30808)", 7 cases, on the real SeedLoaderService: off by default; asked for, 3 rows inserted, owner_id and created_by the installer, a contact's account the id of the account it names, no ledger entry and no registry package; the purge deletes exactly 3 and keeps a user row, a second purge skips 3; a restart after the copy and after the purge inserts nothing; a re-run answers 409 with no new writes; a non-boolean value, or a managed install, answers 400; an unknown id answers 404 and an unreadable receipt answers 503 with no deletes.
  • packages/qa/dogfood/test/install-local-unmanaged-seed-copy.dogfood.test.ts (new), on the real showcase with one database file across three boots: the CRM example's copy answers 200 and loads its 28 rows inline; every row is owned by the signed-in installer; an activity's account resolves to the copied account it names; a restart inserts no row; the purge deletes exactly the 28 and keeps a row the person wrote; a restart after the purge inserts none back.

Acceptance notes

  • File bytes (premise correction). A seed can carry file bytes: SeedSchema has a files map (packages/spec/src/data/seed-file.zod.ts), and the loader mints a sys_file per row from it. The copy keeps the dataset's files in the receipt and loads it as a managed install does, so a copied seed with files gets its files.
  • Quotas. The framework enforces no record quota on a seed load; the only quota-shaped setting found is LifecycleService's in objectql, which a seed load does not read. Whether a cloud deployment enforces one on top is not verified here.
  • Uninstall. Uninstalling the copy's base removes the receipt items with it and leaves the rows. Purge before uninstalling.
  • Counting and reseeding. The listing's withSampleData count and the reseed-sample-data door read the install ledger, which an unmanaged copy never writes, so neither covers a copy yet.
  • Walled posture. An unmanaged install is still refused under a walled posture; the door nonetheless loads in the caller's active organization when a wall is on, and answers refused without one.
  • Out of scope: the seed list. GET /meta/seed (and getMetaItems({ type: 'seed' })) lists no stored seed item, whoever wrote it, because the list keys items by a body name that SeedSchema does not have. This PR works around it (reads by name) and does not change the list.
  • Out of scope. The comment on data: 'door' in packages/spec/src/kernel/unmanaged-install.ts ("a copy of the metadata cannot carry them") is now stale. It is spec text, so it is left for the spec seat.

Verification

All on head da697f3f (origin/main merged once, at 5620dd94), unless a line says otherwise.

  • Red on main first. With the five production files restored to the merge base e2e6ec7d and the new tests kept: package-unmanaged-install.test.ts -t 30808 9 failed, 2 passed (the two that pass guard behaviour main already has: off by default, and an undeclared key answers 400), for example AssertionError: expected [] to deeply equal [ 'crm_account_seed', 'crm_lead_seed' ] and Error: Invalid unmanaged install request: 1 issue — <root> [unrecognized_keys]; marketplace-install-local-install-modes.test.ts -t 30808 5 failed, 2 passed, for example expected 404 to be 503; the dogfood file 5 failed of 5 (dists rebuilt from base), {"mode":"skipped","reason":"an unmanaged install copies metadata, not data: the package's sample data was not loaded"} and No marketplace install for com.example.crm-copy. Then green on head.
  • Ablations (scripts/ablation-replace.mjs, each mutation landed and the tree restored to HEAD after it):
    • the records array check in unmanagedSeedItems → if (false): red, expected 'The package\'s sample data cannot be …' to contain '`data[0]` (object \'crm_lead\') has n…';
    • the seed runtime-creatable finding → && false: red, expected undefined to deeply equal [ ObjectContaining {"key": "data"} ];
    • the rename skip for seed items in the verb → if (rename === undefined): red, the copy rolled back with seed/acme_account_seed failed spec validation: <root>: Unrecognized key(s) on this seed: `name` (the rewrite reaches the body); the plan-level rename pin alone stays green under this mutation, because the skip lives in the verb, so the verb pin is the one that holds it;
    • the non-boolean seedSampleData 400 → if (false): red, expected 200 to be 400;
    • the managed-install seedSampleData 400 → if (false): red, expected 200 to be 400;
    • the purge's unread-receipt failure → return { datasets: receipt.datasets ?? [] }: red, expected 400 to be 503;
    • the installer as the load's owner → userId: null: red, expected { owner_id: undefined, …(1) } to deeply equal { owner_id: 'usr_installer', …(1) }.
  • Package suites: pnpm --filter @objectstack/metadata-protocol --filter @objectstack/cloud-connection test exit 0 on 5620dd94: metadata-protocol 326 files, 22570 passed, 26 skipped; cloud-connection 73 files, 976 passed. The cloud-connection door file was re-run on da697f3f after the harness fix: 41 passed.
  • Typecheck: pnpm --filter @objectstack/cloud-connection --filter @objectstack/metadata-protocol --filter @objectstack/dogfood run typecheck exit 0.
  • Dogfood: install-local-unmanaged-seed-copy, install-local-unmanaged-mode and install-local-purge-sample-data together: 3 files, 34 passed, after a @objectstack/dogfood^... closure build on the merged tree.
  • Lint: pnpm lint packages/cloud-connection packages/metadata-protocol packages/qa/dogfood exit 0.
  • Dispatch gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, each command run with its exit captured before any pipe; --ran: 83 derived famil(ies) accounted for — 83 run, 0 NOT-MEASURED. The first pass found check:engine-double-contract red (the new door harness's update / delete did not route through assertEngineUpdateDispatch / assertEngineDeleteDispatch); fixed in da697f3f with the ledger written by --write, then exit 0. check-plugin-teardown-shape --self-test needed its pinned fixture commit fetched on the shallow clone, then exit 0. check:dual-build-cjs-loads first exited 3 (a package without dist/ while another build was running), then exit 0 on rerun. The seven gates the ledger file added were run on da697f3f, all exit 0.

Generated by Claude Code

…le data

An unmanaged install asked for its sample data (seedSampleData) has the
import write each seed dataset into the new base as a seed item, the copy's
seed receipt in the environment ledger. The install door loads the rows
from those stored bodies, owned by the installer, and the purge door
removes exactly them for the base's id.

Claude-Session: https://claude.ai/code/session_01FEkQ5pQJCx8STEq28nr3Ej
Co-Authored-By: Claude <noreply@anthropic.com>
… at the door and on publish

Claude-Session: https://claude.ai/code/session_01FEkQ5pQJCx8STEq28nr3Ej
Co-Authored-By: Claude <noreply@anthropic.com>
…, pinned on a real boot

The list read serves no stored seed item (a seed body has no name), so the
receipt is read item by item through the by-name read: the names come from
the import's answer on install, and from the base's active seed rows on purge.

Claude-Session: https://claude.ai/code/session_01FEkQ5pQJCx8STEq28nr3Ej
Co-Authored-By: Claude <noreply@anthropic.com>
…the engine dispatch asserts

Claude-Session: https://claude.ai/code/session_01FEkQ5pQJCx8STEq28nr3Ej
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added area:devpath The road — create, dev, verify, publish/install, connect an agent, iterate domain:server Domain lane — seat card indexed by label:pm:seat labels Nov 8, 2026
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 8, 2026
@github-actions

github-actions Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cloud-connection, @objectstack/metadata-protocol, touching 60 documentable anchor(s).

38 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 216b18a6ccb47dc23d3fe9f6a8575daf54f25dd5.

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

What this run could not see
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 64 of 227 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 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 — 15 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 216b18a6ccb47dc23d3fe9f6a8575daf54f25dd5 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 216b18a6ccb47dc23d3fe9f6a8575daf54f25dd5

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-project-manager · session_01FEkQ5pXkgNXVpCn2GZDjyy

Contract review

Served-tier: TIER_DEFAULT
Head-sha: da697f3fbdcb493ccd58a482a1f946a0e1516156
Local-runs: none

Inputs read: card #30808 (body, triage, unblock, claim, claim amendment, os-dev-report), PR #31398 body and file list (10 files, +1076/−36), the net diff against main, and the check-runs on this head. Code outside the diff (the runtime seed paths, isWritablePackage, the ci.yml dogfood job, the engine-double gate's ledger header) was read to judge the diff, not reviewed.

① Derived judgments

Gate verdicts on this head (check-runs). Green: Check Changeset, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Lint · PM dispatch-gates, Lint · ESLint, Lint · docs & code guards, Lint · scoped · query-options erasure, Lint · scoped · tooling self-tests, Type Check · debt ledger, Type Check · source gates, Check PR Size, Flag docs, Test UI, Plan Test Core shards, filter. NOT MEASURED (in progress at review time): Test Core 1/8 to 8/8, Build Core, Type Check · workspace, Type Check · consumer gates, Lint · CI, release & engine gates (the family that carries check:engine-double-contract), Lint · repo & tooling gates, Lint · scoped · slot lookup & corpus, Live E2E UI. Dogfood Regression Gate reads success, but its three shards are skipped: ci.yml runs them only off pull_request (merge queue and main), so the new dogfood file is NOT MEASURED on this head in CI. Its only evidence is the dev's local run (3 files, 34 tests). It is measured when the PR enters the queue. This PASS judges the code. Landing still waits for every NOT MEASURED family above.

(a) Is the widening limited to the door switch and the verb's in-process type, with no packages/spec change? The spec half holds. No path under packages/spec is in the diff, and ImportUnmanagedPackageRequestSchema is untouched. seedData is destructured off before the strict parse, as actor is, and the strict parse still refuses an undeclared key (pinned by the withSampleData 400 case). git grep finds one caller of importUnmanagedPackage: the install-local door. No HTTP route hands a raw body to the verb, so seedData cannot be stated from outside. The door body is the plugin's own, and no spec schema describes it. The new helpers (unmanagedSeedItems, UNMANAGED_SEED_TYPE, seedDatasetOfReceiptItem) are not re-exported from either package index, so no export surface changes.
The accept-set half is not complete. The diff widens three things, and the declaration names two:

  • POST /marketplace/install-local accepts seedSampleData (boolean, unmanaged only). Named right. The non-boolean and managed-install 400s are pinned and ablated.
  • The verb's in-process request type gains seedData. Named right.
  • POST /marketplace/install-local/:id/purge-sample-data is not named in either Clause-② line. Before, any id without an install-ledger entry answered 404. Now an id that names an installed writable base answers one of three things: 200, deleting the rows its active seed items name; 400 NOTHING_TO_PURGE; or 503. The branch's predicate is registry.getPackage(id) && isWritablePackage(ql, id), and isWritablePackage is true for every project-scoped base, not only an unmanaged copy. So a Studio-authored base's own published seed items are now purgeable through this door. The changeset says the purge works "on a copy". The PR body says "an installed, writable base", which is closer but still does not name the reach. The classification yes (widening) stays right, and the tier is unchanged. The declaration is incomplete and the reach is wider than the card's ask. → should-fix S1.
  • The unmanaged answer's seeded can now take mode: 'inline' or 'refused', and new skipped reasons (no-datasets, seed-receipt-unread: …, seed-error: …). This is door-local and is the shape the managed path already answers. Right.

(b) Can storing the copy's seed items in the environment ledger make any boot, save or publish re-insert rows after a purge?

  • Boot: no. Code read: no boot path applies a sys_metadata seed item. The runtime's seed loaders read the config app's data (inline seed), the artifact's metadata:reloaded payload (hot reload), and the shared seed-datasets list (per-org replay). The unmanaged path never calls applySideEffects, so the copy is never merged into seed-datasets, and an org founded later does not replay it. The copy also writes no ledger entry, so the rehydrate healer never sees it. The dogfood pin is real. It counts rows across all five objects under a system context on one database file, in three boots: after install exactly 28; after restart 28 plus 1 user row; after purge 0 plus 1; after a second restart 0 plus 1, and the surviving name is User Authored Co. That is discriminating. One caveat: the counts are taken as soon as bootShowcase resolves, so a load that ran after boot returned would be missed. No such load exists in the code, so this does not affect the verdict. The cloud-connection restart pin re-mounts only the plugin over a protocol double, so it shows only that the plugin's own kernel:ready loads nothing. That is correct for a unit pin, and the dogfood file carries the real claim.
  • Save: no. Code read: seed rows are applied only by runPublishSideEffects (publishMetaItem) and by publishPackageDrafts, never by saveMetaItem. The pin for this does not discriminate, though. The protocol case calls saveMetaItem({ mode: 'publish' }) while rows a1/a2 already exist, so an applied create-only seed would also insert 0, and the assertion would pass either way. → should-fix S2.
  • Publish: yes, and this is not stated. The purge leaves the receipt (by design; the second-purge case pins it). A later draft and publish of a receipt item runs applySeedBodies create-only, so every purged row comes back. These rows are owned by the loader's D9 rule, not by the installer, because the publish path does no installer stamp. This is how any environment seed item behaves, and it needs an author's deliberate publish, so it is not a defect of this PR. But the changeset and PR body say a later publish "loads no row twice" without the after-purge case, and nothing pins it. → should-fix S3.

(c) Are the seed rows owned by the installer, as ruled (#30324 C / #30680 A)? Yes. loadUnmanagedCopySeeds runs the managed path's runInlineSeed with owner: { userId, door: 'unmanaged install' }, and stampLoadOwner / stampSeedOwner write owner_id and created_by on every row the load created. The diff adds no sample-data read rule, so the rows follow ordinary permissions. The copy's objects are new, so every loaded row is a created row, and a matched pre-existing row cannot keep a foreign owner. Pinned twice: the unit case checks owner_id/created_by equal usr_installer on every row, and the dogfood file checks that the owner set and the creator set across all 28 rows are both exactly the signed-in person's id. That id is taken from a row the same person wrote through REST. Ablation userId: null goes red. The card also asked the PR to state a consequence: per-role answers in the customer's environment will differ from the gallery's per-persona transcripts. The PR states the collapse to one owner, but not that consequence. → should-fix S4.

Card asks.

  • Ask 1 (optional, off by default): pinned in both packages.
  • Ask 2 (receipt vs marker column weighed, receipt chosen with no new key): done in the PR body.
  • Ask 3 (externalId stable under the rename): the plan pin covers it, and the verb pin covers it with ablation red. The rename skip for seed items in the verb keeps the free-text rewrite out of record values.
  • Ask 4 (load order): pinned. The child dataset is listed first and multiPass resolves account; the dogfood activity's account resolves too.
  • Ask 5 (409 re-run): pinned at both layers with no new writes.
  • Ask 6 (file blobs and quotas): the dev's premise correction is right. SeedSchema.files exists (seed.zod.ts:343), and the receipt keeps it (the body is { ...dataset, object }, filtered to SeedSchema.shape on read). But no case in the diff copies a dataset that carries files, so the changeset's "a dataset's files map is loaded as on a managed install too" is unpinned. → should-fix S5. Quotas are stated honestly as framework none and cloud unverified.

Correct by reading.

  • The plan puts seed items after every other item, so each object's table exists first.
  • A seed item refused by the save door rolls the whole copy back (pinned, failedItem).
  • The isRuntimeCreatable('seed') finding is on key data (pinned, ablated).
  • An unreadable receipt is never folded into "no sample data": the install answers skipped with its reason, and the purge answers 503 with nothing deleted (pinned, ablated).
  • The managed purge path is unchanged apart from moving into purgeSeedDatasets with a non-null entry. The ledger flag write is now guarded on entry.

(e) scripts/engine-double-contract.pinned.json. This is a legitimate pin update, and no gate is weakened. The file's own header calls it the RETAINED coverage ledger, which may only grow; the debt ledger is engine-double-contract.baseline.json, which is not in the diff. The edit only adds two rows, update and delete for marketplace-install-local-install-modes.test.ts, each pinned: 1, and removes none. They match the new harness, whose update/delete now call assertEngineUpdateDispatch / assertEngineDeleteDispatch. That is the dev's fix for the gate's first red. Nothing is loosened. The family that runs this gate on CI (Lint · CI, release & engine gates) is NOT MEASURED on this head.

② Semver level

(d) The changeset is '@objectstack/cloud-connection': patch and '@objectstack/metadata-protocol': patch, with both declaration lines line-initial: Clause-②: yes (widening) and Metadata-protocol: none. Level right. Under AGENTS.md § Post-Task Checklist 3, none takes patch, and a non-protocol widening ships patch. Metadata-protocol: none is right too. Nothing a metadata author writes changes: seed is an existing type, data an existing key, and the switch lives in an HTTP door body, not in metadata. Neither moved package re-exports a new symbol.

Clause-②: yes (widening) is the right kind and arm. As written, the line is incomplete in both the PR body and the changeset: it names the install door's seedSampleData and the verb's seedData, but not the purge door's new accept set or that set's reach to every writable base (S1).

Text vs diff. The changeset matches the diff except in three places:

  • "on a copy" understates the purge reach (S1).
  • It does not say that a seedless writable base, including an unmanaged copy installed without seedSampleData, now answers 400 NOTHING_TO_PURGE ("This package declares no seed datasets.") where it answered 404. For a copy whose package does declare data, that message is misleading (nit N1).
  • "a copied seed item … published later behaves as every environment seed item does" leaves out the after-purge re-insert (S3). Its files sentence is unpinned (S5).

③ Boundary flags

  • open_questions: none.
  • Premise correction: seeds carry file bytes. Accepted. It is right against seed.zod.ts, and the claim amendment recorded it before build. The files load is unpinned (S5).
  • Premise correction: quotas. Accepted as stated. Whether the hosted cloud enforces a record quota is unverified here. That belongs to the cloud#3626 deep-link owner, not to this repo. Escalated to the seat to carry onto that card.
  • Premise correction: the verb body lives in protocol-package-commits.ts. Accepted. It is covered by the claim amendment's file surface.
  • Out-of-scope class a: getMetaItems({ type: 'seed' }) lists no stored seed item. Real, and the PR works around it by reading by name. The report gives dedupe words but the finding is not filed. Escalated: the seat files it (or dedupes it) before landing. Each purge of a copy depends on the by-name workaround, and a fix to the list must not break it.
  • Out-of-scope: the stale data: 'door' comment in packages/spec/src/kernel/unmanaged-install.ts. Right not to touch, since it is spec text. Escalated to the spec seat as noted. It should be filed, not left as "noted".
  • Out-of-scope: uninstalling a copy removes the receipt and leaves the rows, and withSampleData / reseed do not cover a copy. It is stated in the PR's Acceptance notes. Once the hosted deep link (cloud#3626) turns the switch on, users hit this through the hosted flow. Escalated: the seat files a follow-up card (should-fix S6). There is also no door that re-loads a copy's receipt after a seed-receipt-unread or seed-error install, since a re-run answers 409. That belongs in the same card.

Must-fix: none.

Should-fix:

  • S1. Name the purge door's widening in the Clause-② line of the PR body and the changeset. Either narrow the non-ledger branch to unmanaged copies, or state in writing that it reaches every installed writable base's seed items, Studio-authored bases included.
  • S2. Make the save pin discriminating: run saveMetaItem({ mode: 'publish' }) on a receipt item with the rows absent (the post-purge state), and assert 0 inserts.
  • S3. State in the changeset and PR body that a purge leaves the receipt, and that publishing a receipt item afterwards loads its rows again under the publish path's owner rule, not the installer's. Pin it, or state that it is left unpinned.
  • S4. Add the card's required consequence sentence: per-role answers in the customer's environment differ from the gallery's recorded per-persona transcripts, and the gallery proves per-role behaviour through those transcripts, not live.
  • S5. Pin the copy of a dataset that carries files (a sys_file minted per row), or soften the changeset's files sentence to what is measured.
  • S6. File the follow-ups: the getMetaItems seed listing, the stale spec comment, and uninstall/reseed/count for a copy.

Nits:

  • N1. The purge of a copy installed without seedSampleData answers 400 "declares no seed datasets" for a package that may declare some.
  • N2. A receipt item a person edited into something that is not a dataset makes every later purge answer 503 "Retry once the metadata store answers", which is the wrong prescription for that cause.

Implemented-by: claude/project-thread-b11gkm
Reviewed-by: session_01FEkQ5pXkgNXVpCn2GZDjyy

VERDICT: PASS

…oor made

A door-private record of each copy (beside the install ledger, removed by an
uninstall cleanup) is now what lets the purge reach a base without a ledger
entry; every other writable base keeps the 404. A copy made without sample
data says so. Pins for a save after a purge, a publish after a purge, and a
dataset that carries files.

Claude-Session: https://claude.ai/code/session_01FEkQ5pQJCx8STEq28nr3Ej
Co-Authored-By: Claude <noreply@anthropic.com>
…ish-after-purge owner rule

Claude-Session: https://claude.ai/code/session_01FEkQ5pQJCx8STEq28nr3Ej
Co-Authored-By: Claude <noreply@anthropic.com>
… cleanup's outcome

Claude-Session: https://claude.ai/code/session_01FEkQ5pQJCx8STEq28nr3Ej
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-project-manager · session_01FEkQ5pXkgNXVpCn2GZDjyy

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 86246c5b01e81a07ff8d0acd5dddc082ae27b932
Local-runs: none

Round 2, a delta review against the round-1 record (comment 6718084382, head da697f3f). Inputs read: card #30808 (body and every comment; nothing new since round 1), PR #31398 as it stands on GitHub (body and file list, 13 files), the net diff against main, the delta da697f3f..86246c5b (commits 7c35c693, 97808a91, the merge 79be15ea, and 86246c5b), and the check-runs on this head. The dev's proposed round-2 body (30808-pr-body-r2.md) and gates file were read as the dev's account, not as evidence. Code outside the diff was read to judge the diff, not reviewed: the protocol's deletePackage and runUninstallCleanups, the runtime's DELETE /packages/:id dispatcher, purgeSeedRows, and the cloud-connection route ledger.

① Derived judgments

Gate verdicts on this head (check-runs). Green: Check Changeset, Check PR Size, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · scoped · tooling self-tests, Flag docs, Test UI, Plan Test Core shards, filter, Auto Label. NOT MEASURED (in progress at review time): Build Core, Test Core 1/8 to 8/8, Lint · CI, release & engine gates (the family that carries check:engine-double-contract), Lint · ESLint, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Live E2E UI, and Type Check · workspace, consumer gates, debt ledger and source gates. Dogfood Regression Gate reads success, but its shards are skipped off the merge queue, as in round 1. The dogfood file is NOT MEASURED in CI on this head; it is measured in the queue. This PASS judges the code. Landing waits for every NOT MEASURED family.

The merge. 79be15ea brings in origin/main at 3a0aa915 (#31388) with no hand resolution: the combined diff of the door file is empty. #31388 changes only the cloud-fetched branch's register catch (refusesBareNameHeldElsewhere). It does not touch the unmanaged path, the purge or the uninstall, and it is not in this PR's net diff. Nothing new on the contract face comes from the merge.

S1 — the purge door's reach. Closed in code. The declaration is closed in the changeset and still open in the PR body.

  • Code. unmanagedCopyPurgeDatasets now reads the door's own record (UnmanagedCopyRecord, one JSON file per base under unmanaged-copies/ in the ledger directory) before anything else. With no record it answers null, which the handler turns into 404. With a record it then requires the base to be installed and writable, as before. A recorded copy made without seedSampleData answers notSeeded. The record is written in installUnmanaged right after the verb returns and before any row is loaded. The only other writer is the uninstall cleanup that removes it. No HTTP input names a record, and isRecordEntry rejects a file whose baseId differs from the id asked, so a filename sanitizer collision reads as a failure and never as a reach. The pins are discriminating by reading. A Studio-authored, installed, writable base with an active seed item naming an existing row answers 404 with no write. Before this patch that case reached the delete. A copy with its record removed answers 404. The managed path (ledger.has) is unchanged.
  • Can the purge reach a base that is not a recorded copy? Not by id. By row, a recorded copy's receipt can name another base's object in a dataset's object. A manage_metadata holder can edit a copy's seed item, and purgeSeedRows deletes under SEED_WRITE_EXECUTION_CONTEXT without checking that the object is the base's own. But the same capability can already install an inline managed manifest whose datasets name any object, and purge that. So this reach is not new, and it is no finding.
  • A stale record. The record says only that the door once copied this id. It does not bind to the base instance. A record outlives its base in four cases: the base is removed by a protocol without registerUninstallCleanup; the cleanup's rmSync fails, which is reported as success: false on the answer but leaves the file; the base is removed on a runtime whose ledger directory is not this one (another replica, or a run without the door composed); or an uninstall lands between the verb's return and the record write. In each case the purge answers 404 while no base holds the id. But a later base under the same id is reached. The PR body itself names the likeliest one: -copy is also the Studio duplicate's default id. The door's own docblock ("the purge still answers 404 for a record whose base is gone") stops one step short of that, and the changeset's "every other writable base, a Studio-authored one included, keeps the 404" is stated without the condition. → should-fix S7.
  • A record for an uninstalled base. The base check runs after the record read, so the purge answers 404. The protocol's deletePackage runs every cleanup for any id it is asked about, partial failures included, and the import's own rollback goes through it too. So the normal path removes the record, and a partial uninstall fails closed (404).
  • A concurrent uninstall. A purge that has passed the base check deletes only the rows its receipt named, in the objects the receipt named. Rows the uninstall leaves behind anyway, or engine errors counted in errors. No new reach.
  • An unreadable record. The purge answers 503 with nothing deleted (pinned, and ablated per the dev's account). Two wording faults, below as N4.
  • Declaration. The changeset's Clause-② line now names all three widenings: the install door's seedSampleData, the purge door now reaching a copy this door made (and that it answered 404 before), and the verb's seedData. That is right. The PR body on GitHub is still the round-1 body, and its Clause-② line names only two of them. The dev's proposed 30808-pr-body-r2.md carries the right line but is not posted. → should-fix S8.

S2 — the save pin. Closed. The new case saves the receipt item with saveMetaItem({ mode: 'publish' }) with every row of the dataset absent, the post-purge state, and asserts 0 inserts and an empty table. A save that applied the seed would insert both rows, so the pin discriminates.

S3 — publish after purge. Closed in the changeset and pinned. The PR body is open (S8). The changeset now says it: "Published after a purge, it loads its rows again under the publish rule, not the installer's: the publish names no person, so those rows carry no owner_id or created_by." It also says the purge leaves the receipt ("an uninstall removes the receipt"; "Published after a purge"). The new case drafts and publishes with no rows present and asserts inserted: 2 with organization_id: 'org_1' and owner_id/created_by undefined.

S4 — the persona consequence. Closed in the changeset. The PR body is open (S8). The changeset now carries the card's required sentence: rows under different personas collapse to one owner, "so per-role answers in the customer's environment differ from the gallery's per-persona transcripts." That is the consequence the card's governing-rulings paragraph says to state.

S5 — files. Closed. The changeset no longer claims the bytes load on a copy: "loading those bytes on a copy is not exercised by a test here." Two new pins measure what the PR does implement. The verb keeps a dataset's files map in the receipt verbatim. seedDatasetOfReceiptItem keeps files (and every SeedSchema member) and drops an undeclared key.

N1 — the message for a copy without sample data. Closed. A recorded copy made without seedSampleData answers 400 NOTHING_TO_PURGE "No sample data was copied into '…': it was installed unmanaged without seedSampleData". The pin asserts the text and asserts that the old "declares no seed datasets" wording is absent. A copy whose receipt has since been emptied gets its own 400 sentence (nothingToPurge). The managed sentence is unchanged.

New on this head: the cleanup outcome on every uninstall answer. The door registers install-local:unmanaged-copy-record with the protocol's uninstall cleanups at kernel:ready. It runs on every uninstall of every package, not only of copies. Its outcome { name, success: true, removed: 0 } now appears in:

  • data.cleanups of DELETE /marketplace/install-local/:id (pinned by 86246c5b);
  • cleanups of the protocol's deletePackage, which DELETE /packages/:id serves as persisted.cleanups, and the PACKAGE_DELETE_PARTIAL 400's details.cleanups.

No published response shape changes.

  • UninstallCleanupOutcome[] is unchanged, and it is a list of named outcomes that already carries other plugins' entries (runtime.package-jobs, plugin-security, datasources, package-state) in any composed runtime.
  • cleanups is not in any packages/spec schema. UninstallPackageApiResponseSchema does not declare persisted, which is pinned as undeclared residue. The install-local DELETE is server-only in the route ledger, and DeletePackageResponse is a metadata-protocol TS type, unchanged.
  • No client or UI reads cleanups.

It is not a Clause-② item. It is a visible change on every uninstall answer, and neither the changeset nor the proposed body states it fully. The precedent is the runtime.package-jobs changeset, which named the same kind of change. → nit N3.

② Semver level

The changeset is still '@objectstack/cloud-connection': patch and '@objectstack/metadata-protocol': patch, with Clause-②: yes (widening) — … and Metadata-protocol: none both line-initial. Level right.

  • The patch adds one module, marketplace-install-local-unmanaged-copies.ts. Neither package index nor package.json exports it.
  • The diff has no packages/spec path.
  • The record is door-private storage, not metadata, so Metadata-protocol: none holds.

Clause-②: yes (widening) is the right kind and arm. The changeset's line is now complete. The PR body's is not (S8).

Text vs diff. The changeset matches the diff except in two places:

  • "every other writable base … keeps the 404" holds only while the record dies with its base (S7).
  • It does not say that every uninstall answer now carries the copy-record cleanup's outcome (N3).

③ Boundary flags

  • open_questions: none.

  • S6 from round 1 (follow-ups). Partly done:

    Escalated again to the seat: file both before landing, or record why not.

  • The option-A ruling (a door-private record, with purge narrowed to recorded copies) that this patch implements is not on the card or the PR. The card's last comment is the os-dev-report. Escalated: the seat posts the ruling on the card so that the record trail carries what S1 was closed against.

  • Round-1 N2 (a receipt item edited into a non-dataset answers 503 "Retry once the metadata store answers") is unchanged. It is folded into N4.

  • Premise corrections (files, quotas, the verb's file): unchanged from round 1. They stay accepted, and the files wording is now measured-only (S5).

Must-fix: none.

Should-fix:

  • S7. A stale record reaches a later base under the same id. Either bind the record to the base instance, or state the residual. To bind it, store something the base carries that a re-created base cannot share (its install row's id or created-at, or the copy's source identity checked against the base manifest), and answer 404 on a mismatch. To state it, say in the changeset and the door's docblock that a record outlives its base when the base is removed without this door's cleanup (a protocol without the registry, a failed removal, another runtime's ledger directory), and that a later base under the same id, such as a Studio duplicate's default -copy id, is then reached.
  • S8. Post the round-2 body. The PR body on GitHub is still round 1. Its Clause-② line omits the purge door's widening (S1), and it lacks the S3 publish-after-purge rule and the S4 persona consequence. 30808-pr-body-r2.md carries all three, and must be on the PR before it lands.

Nits:

  • N3. State that the install-local:unmanaged-copy-record outcome now rides on every uninstall answer: the install-local DELETE and the protocol's deletePackage (DELETE /packages/:id, persisted.cleanups). The changeset is silent, and the proposed body names only the install-local DELETE.
  • N4. An unreadable record answers 503 with "its seed items could not be read … Retry once the metadata store answers". The cause is a local file, so the prescription is wrong (the warn log carries the true cause). It is also asked before the base check, so a gone base with a corrupt record answers 503, not 404. A record that cannot be written at install is only logged, and the install answer does not tell the caller that its rows cannot be purged through this door.

Implemented-by: claude/project-thread-b11gkm
Reviewed-by: session_01FEkQ5pXkgNXVpCn2GZDjyy

VERDICT: PASS

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:devpath The road — create, dev, verify, publish/install, connect an agent, iterate documentation Improvements or additions to documentation domain:server Domain lane — seat card indexed by label:pm:seat size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An unmanaged install can copy the package's seed data, removable as one batch, owned by the installer (gallery demos need data)

1 participant