Skip to content

feat(metadata-protocol,runtime,service-automation,spec)!: the protocol refuses every organization-scoped write; an uninstall is environment-wide (ADR-0131 D6/D12) - #22515

Merged
os-zhuang merged 19 commits into
mainfrom
claude/issue-15206-s4-protocol-env-only
Oct 10, 2026
Merged

os-zhuang merged 19 commits into
mainfrom
claude/issue-15206-s4-protocol-env-only

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Refs #15206 (S4)
Refs #22350
Clause-②: yes (narrowing)

Stage S4 of #15206 (ADR-0131 C5): the metadata protocol refuses every organization-scoped write, and the per-organization write path behind it is deleted. It carries #22350 (ruling A): the organizationId / allTenants keys and both TENANT_SCOPE_REQUIRED refusals of the package uninstall retire.

#15206 remains open for S5. #22350 is closed by the seat with this stage's landing record, not by this PR.

What changes

Protocol (@objectstack/metadata-protocol)

  • One refusal for every write verb. organizationScopedWriteRefusal is asked FIRST, before any read: saveMetaItem (draft and publish), publishMetaItem, deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata.
    • Answer: 403 NOT_OVERRIDABLE for every type. The first sentence names the tenancy posture in force.
    • The five-type allowOrgOverride exemption is gone, and the OS_METADATA_WRITABLE hatch no longer opens an organization scope.
    • organizationId is removed from each verb's request type.
  • Deleted as unreachable or per-organization:
    • orgScopedWriteRefusal (the old exemption)
    • anonymousFormIntakeOrgScopeRefusal / anonymousFormIntakeReopenRefusal / envWideRawViewRows
    • resolveMetaItemOrgScope / resolveDraftOrgScopeForPublish
    • the per-draft and per-item org-scope threading in publish, the package publish, discard, revert, rollback, duplicate and adopt-orphans.
  • The audit and commit ledgers write organization_id NULL. MetadataAuditEntry.organizationId is typed null.
  • revertCommit refuses a commit row recorded in a legacy organization layer.
  • migrateStoredMetadata reports an organization-scoped row as skipped, naming the promotion ceremony (ADR-0131 C7), and never re-saves it.
  • applyRemoteMetadataMutation converges the registry on the environment row.
  • deletePackage ([decision] once ADR-0131 C5 makes package metadata environment-wide, does deletePackage's organization-scope guard (organizationId / allTenants, TENANT_SCOPE_REQUIRED, #7780) retire? #22350 A).
    • A request carrying organizationId or allTenants (any value) → 400 INVALID_REQUEST, nothing removed.
    • With neither key, the uninstall is environment-wide: every row bound to the package.
    • Legacy organization-scoped rows of the package are removed with it through the repository, so the history tombstone is kept.
    • UninstallCleanup args drop organizationId.
  • findPlatformScheduleOrgGaps loses its organizationId input, because every write is platform-level now. Its hint no longer prescribes "publish into an organization".
  • Seeds applied on publish get no caller organization: a seed dataset names its own organization (ADR-0131 D9, §12).

Packages door (@objectstack/runtime, domains/packages.ts)

  • No organization is threaded into any /packages verb, nor into the commit list or assemblePackageManifest (S3's carried finding).
  • requireUninstallOrganizationScope and the door's 400 TENANT_SCOPE_REQUIRED are deleted. Who may uninstall stays with requireManageMetadata plus the read-only-package gate.

Callers

  • @objectstack/service-automation flow-credential-migration.ts: a legacy organization-scoped flow row is not re-saved. It is reported as failed with NOT_OVERRIDABLE and logged at error, stating that the row still carries the credential in cleartext.
  • @objectstack/cloud-connection: the runUninstallCleanups runner type drops the retired organizationId. Type-only; the emitted JS is unchanged.

Spec (@objectstack/spec)

  • organizationId leaves the SaveMetaItem, PublishMetaItem and DeleteMetaItem request schemas. The authorable-surface lines go with them: the defs are not reachable from a metadata root, and check:authorable-surface proves it.
  • Both TENANT_SCOPE_REQUIRED ledger rows are removed.
  • ADR-0087 semantic entries metadata-write-organization-scope-refused and package-uninstall-environment-wide; registry.ts and the reference docs are regenerated.

Docs

  • environment-variables.mdx (OS_METADATA_WRITABLE no longer opens organization scope).
  • metadata-service.mdx (uninstall is environment-wide).
  • public-data-collection.mdx §4: an organization copy of a public form is a legacy row that no write can edit. The organization-scoped save check is deleted (patch round 1).

Not in this PR (by ruling)

Cross-lane paths

Size

About 6,500 changed lines (+1,639 / −4,857, 93 files at 2318d0ba1), over the human-merge line (3,000 on main). Stage 0's named split seam does not work, for two measured reasons:

  • It is coupled. The packages door must stop sending an organization in the same landing as the protocol refusal, or every /packages write by an org-active caller answers 403.
  • It does not get under the line. The protocol package alone is about 3,800 changed lines: source about 1,580, tests about 2,260.

So this lands on the human-merge route as one PR. Most of the volume is deleted tests of deleted behaviour.

Verification

Three rounds measured this PR. The later two are summarised first; the first round's detail follows.

  • Merge-main round (head 2318d0ba1, report 6089617413). It merged origin/main 5910b5e3e through os-regen-merge.sh, then regenerated spec-changes.json and protocol-upgrade-guide.md. Every step-18 id is present in registry.ts, spec-changes.json and the upgrade guide. The packages/spec full suite passes 19,751. All 125 derived gates exit 0. packages/runtime is NOT MEASURED in this round: the merge touched no runtime file.
  • Patch round 1 (head 7054e63de, report 6087716647). This round followed contract review 6085867875, which FAILed. The packages/spec full suite passes 19,716. The packages/runtime full suite passes 345 files, plus the re-premised audit-meta-item-org-scope at 7/7. All 125 derived gates exit 0.

First round (head 537fa89c8, merge base 2e10c9ab)

Identity pin. protocol.org-scoped-write-refused.test.ts passes 302 tests. It is enumerated over every DEFAULT_METADATA_TYPE_REGISTRY type plus the plugin type acme_widget, and covers:

  • save (draft and publish), publish and package publish → 403 NOT_OVERRIDABLE, with nothing persisted;
  • the first sentence naming each posture (single, group, isolated);
  • the hatch closed to organization scope;
  • environment-wide controls accepted;
  • a legacy organization draft left as stored.

Reverse verification. The fix was committed first. Using scripts/ablation-replace.mjs with a trap, the five-type exemption was re-inserted at the save door (if (!registryAllowsOverlay(request.type)) around the refusal):

  • Predicted: the tier-A cases red, everything else green.
  • Measured: 17 red / 285 green. The red cases are the 10 tier-A save cases (5 types × draft/publish), plus 7 view-based cases (posture sentence ×3, hatch, topology, and the legacy-overlay re-save ×2).
  • Every other type's case and every control stayed green. The restore was verified as blob equal to HEAD with an empty git diff HEAD.

Suites (local, under the verification lock):

  • @objectstack/metadata-protocol full suite: 223 files, 28,304 passed, exit 0.
  • @objectstack/runtime packages domain plus the touched integration files: 30 files, 498 passed.
  • The repaired objectql, rest, service-automation and metadata-core files each pass.
  • objectql typecheck plus check:test-typecheck: exit 0.
  • dogfood: 11 files that exercise metadata writes and package install / uninstall / publish, 167 tests passed.

Typecheck: spec, metadata-protocol, objectql, rest, service-automation, runtime, cloud-connection, cli and dogfood all pass at the merged head.

Gates: dispatch-gates --commands derived 125 families. All 125 were run, and --ran reconciles them with exit codes: 125 run, 0 NOT-MEASURED, all exit 0.

  • Two were red on the first pass and are fixed in this PR: check:error-code-provenance (the flow-credential move's NOT_OVERRIDABLE row is now registered) and check:objectql-double-limit.
  • Four first returned prerequisite exits — a shallow clone, or a dist mid-rebuild — and were re-run green after deepening and rebuilding.
  • check:generated reports all 15 artifacts up to date.

objectui at the pin f0268ad784: it sends no organizationId or allTenants on any metadata or package write, and imports none of the changed request types or TENANT_SCOPE_REQUIRED (git grep: 0 hits).

NOT MEASURED locally in the first round (declared to CI):

  • the spec suite, which was measured in patch round 1;
  • the full runtime suite, which was measured in patch round 1;
  • the rest, objectql, cli and dogfood suites beyond the files named above;
  • pnpm lint.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NcYr731pAx356wDedHe9Ca


Generated by Claude Code

claude added 13 commits October 9, 2026 14:02
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cloud-connection, @objectstack/metadata-protocol, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec, touching 75 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json, packages/spec/spec-changes.json, packages/spec/src/stack.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

42 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 5910b5e3ed5414692ae74df28e155bf16f12b5cd.

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

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json, packages/spec/spec-changes.json, packages/spec/src/stack.zod.ts) — pages documenting those are invisible to this run
  • 5 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 — 145 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 5910b5e3ed5414692ae74df28e155bf16f12b5cd → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5910b5e3ed5414692ae74df28e155bf16f12b5cd

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7231c58fae60ff09251bc91d4b98f7733a09313a
Local-runs: none

Inputs: card #15206 (body and all 36 comments, through the S4 report 6085458127); #22350 (body, its 5 comments: the grade 6068216919, the ruling 6070750378, the claims 6071732822 and 6082130422, the release 6074820322); PR #22515 (body, its 89-file list, the net diff against its merge base 2e10c9abe0 — 89 files, +1,502 / −4,802, equal to the file list — and its one comment, the docs-drift note); S3's last record 6081190446 on PR #22447 for the findings it carried into S4; the head's check-runs, read three times (39 runs with 20 pending at the first read; 40 runs at the second, 28 success, 4 skipped, 1 failure, 7 pending; and the reading at posting time below). The failing shard's log was read through the Actions job-log endpoint. Source was read with git show / git grep at the head, on origin/main at 446c8b2a (the PR's recorded base) and at 4e9fe9ff6a (where origin/main stood by the end of the review); objectui was read the same way at this head's pin f0268ad784; a git merge-tree of the head against main was read for conflicts. HUMAN_MERGE_LINE_THRESHOLD was read in scripts/pm/check-governed-merges.mjs on origin/main (line 1115): 3000, and the file's self-test pins it as the ruled value. Nothing was checked out, built, run or re-run.

① Derived judgments

Gate verdicts on this head, as read. Two shards of the required Test Core context are red. Test Core (1/6) — failure, the packages/spec suite: packages/spec/src/api/protocol.test.ts, six cases at lines 1825, 1860, 2289, 2306, 2409 and 2436 — for each of SaveMetaItemRequestSchema, PublishMetaItemRequestSchema and DeleteMetaItemRequestSchema, the "accepts the full request and PRESERVES every member through parse" fixture still carries organizationId: 'org_alpha', and the "optional strings stay optional and reject non-strings" loop still enumerates organizationId. The schemas are not .strict(), so the retired key is STRIPPED at parse: the fixture parses true with the key gone (deep-equal fails) and organizationId: 42 parses true. 1 test file failed of 630; 6 tests failed of 18,799. The dev edited this file's typed-literal sections only (the two @ts-expect-error lines at 2364 and 2548), and the report measured spec typecheck and check:generated while naming the spec test suite neither as run nor as NOT MEASURED. Test Core (5/6) — failure, the packages/runtime suite: packages/runtime/src/audit-meta-item-org-scope.integration.test.ts (#8747, auditMetaItem's organization-scope read on a real driver) — not in this PR's file list — seeds its three rows through the production writer with organizationId: ORG_A / ORG_B (seedThreeOrgs, line 102), which this head refuses first (organizationScopedWriteRefusal, the message quoted in the log), so all 7 cases fail in the seed; 1 test file failed of 343, 7 tests failed of 4,869. The dev's report declared the full runtime suite NOT MEASURED, and this is what it would have measured. The read the file pins is S5's; its premise ("rows are seeded by the production writer … so the stamps under test are the stamps production produces") no longer exists on this head — production stamps no organization on a sys_metadata or sys_metadata_audit row — so the fix is the one this PR already applies to package-uninstall-org-scope and the public-form dogfood: plant the legacy rows at rest and keep the read assertions for S5 to re-premise. Green at the reading taken before posting: TypeScript Type Check and its four legs (· consumer gates, · source gates — the leg that runs check:authorable-surface, check:spec-changes, check:upgrade-guide and check:docs — · debt ledger, · workspace), Build Core, Build Docs, Test Core (4/6) and (6/6), Dogfood Regression Gate (rollup and 1/3 to 3/3), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, Check Changeset, Check PR Size, Spec property liveness, the docs-link and docs-flag checks and the four claim and closing guards; skipped by contract: Console Pin Gate (the pin f0268ad784 is unchanged on this head and on main), Packed-tarball smoke (opt-in), and the body-edit twins of Auto Label / Check PR Size; still pending at that reading: Lint & Repo Gates (which carries check:migration-registry, check:error-code-provenance and check:durability-log-level), Test Core (2/6) and (3/6) — recorded as pending, not as passes. Not governed: no path under .claude/, docs/adr/, skills/, docs/NORTH-STAR.md, AGENTS.md or CLAUDE.md; head repo is the base repo; draft, auto_merge unset, mergeable: true / blocked (draft with pending checks). Size: 6,304 changed lines, over the 3000 line — the human-merge route; the route is the seat's, the split claim is judged in ③.

Accept-set and public-surface changes, each read off the net diff and judged.

  1. One refusal, asked FIRST, on every write verb. organizationScopedWriteRefusal (protocol.ts) is asked before any read in saveMetaItem (draft and publish), publishMetaItem, deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata, through one refuseOrganizationScopedWrite(subject, request) that reads organizationId off whatever arrived (the key is gone from every verb's declared request, so a caller still sending it is untyped by construction). 403 NOT_OVERRIDABLE for every type — no new code, no ledger widening — and the first sentence names the posture in force (resolveTenancyPosture, with an "unrecognized" fallback); '', null and undefined read as no organization, the old !organizationId reading. The five-type allowOrgOverride exemption, the static-registry carve-out that let plugin-registered types through, and the OS_METADATA_WRITABLE arm are all gone. RIGHT: the card's item (2), the stage plan's S4 row, and D6. Pinned by the identity pin, which is enumerated over DEFAULT_METADATA_TYPE_REGISTRY plus acme_widget and covers both save modes, the per-item promotion (no draft needed, the refusal precedes every read), a legacy organization draft left as stored, the package publish, the hatch, topology, the three posture sentences and the environment-wide controls; the dev's reverse verification (the exemption re-inserted: 17 red / 285 green, restored blob-equal) is the recorded direction.
  2. Deleted as unreachable or per-organization: orgScopedWriteRefusal, anonymousFormIntakeOrgScopeRefusal, anonymousFormIntakeReopenRefusal, envWideRawViewRows, resolveMetaItemOrgScope, resolveDraftOrgScopeForPublish; the three anonymousFormIntake* imports leave protocol.ts; the per-draft and per-item scope threading leaves publish, promote, discard, revert, rollback, duplicate and adopt-orphans; lockWriteRefusal / assertLockAllowsWrite / assertLockAllowsDelete / the conflict audit lose their organization inputs. Residue at head: the old names survive only in CHANGELOGs and three test-file comments. RIGHT — and the deletion of the two anonymous-form refusals is what makes a published page false (item 14).
  3. The audit and commit ledgers write organization_id NULL. MetadataAuditEntry.organizationId is typed null, every recordMetadataAudit call passes null, recordPackageCommit loses orgId. RIGHT (D7).
  4. The package verbs see environment drafts only. publishPackageDrafts, discardPackageDrafts and revertStoredPackage list through getOverlayRepo(null), whose packageScopedRowWhere(null, …) is organization_id IS NULL, so a legacy organization draft is never promoted, discarded or reverted by them (pinned in the identity control and in protocol-publish-drafts-org-scope). RIGHT.
  5. revertCommit refuses a commit row recorded in a legacy organization layer (same code, its own remedy sentence), every item reverts environment-wide, and the revert commit is recorded environment-wide. rollbackToPackageCommit plans from listCommits({ packageId }) with no organization — the package's whole timeline — so a legacy organization commit in the path lands in failed[] and success reads false: loud, never silent. RIGHT. The two deleted runtime suites (Four more strict organization_id equalities left in protocol.ts — two measured (revertCommit / rollbackToPackageCommit), two unverified (duplicatePackage / reassignOrphanedMetadata) #7819 tier 1, revertCommit attributes its revert commit to the request's organization even when the commit it reverted was env-wide — newly reachable as of #7819 tier 1 #7860) pinned the organization resolution this diff removes; the environment-wide rollback stays pinned in package-list-commits-org-scope.
  6. duplicatePackage and reassignOrphanedMetadata scan organization_id IS NULL. No legacy organization body is copied environment-wide under a new package (a promotion this verb has no business performing, and two bodies on one target key), and no legacy row is rebound. RIGHT — the dev's Q2, answered A in ③.
  7. migrateStoredMetadata reports an organization-scoped row skipped naming C7 and never re-saves it; applyRemoteMetadataMutation converges on the environment row whatever the event names. RIGHT.
  8. deletePackage ([decision] once ADR-0131 C5 makes package metadata environment-wide, does deletePackage's organization-scope guard (organizationId / allTenants, TENANT_SCOPE_REQUIRED, #7780) retire? #22350 A). A request carrying organizationId or allTenants — present with any value, undefined and false included (hasOwnProperty) — answers 400 INVALID_REQUEST before any read; with neither key the where is { package_id } alone, so every row bound to the package in this environment is removed, environment-wide and legacy organization rows alike; a legacy row goes through removeLegacyOrganizationRowOnUninstall (the repository's delete, intent runtime-only, history tombstone kept, an audit row with organization_id NULL and a note naming the organization). The door: requireUninstallOrganizationScope and its 400 TENANT_SCOPE_REQUIRED are deleted; requireManageMetadata and the read-only-package gate stay; deletePackage is handed { packageId, keepData? }. Pinned on both sides: the unit suite (four key shapes, nothing removed, the registry untouched) and the runtime integration suite (five key shapes refused with the seed untouched; neither key removes 5 of 5 and leaves the other package alone), and the door test pins deleteRequests equal to [{ packageId }] for a member, a removed member and an org-less caller. RIGHT, as ruling 6070750378 decides. Two residue classes, carried: assertAllowed admits a runtime-only delete for overlay-allowed, runtime-creatable and plugin-registered types, so a legacy organization row of a create-closed static type (reachable only through a pre-S4 hatch) would be refused NOT_CREATABLE into failed[] with success false — loud; and the legacy path never passes dropStorage, so a hatch-written organization-scoped object row's table would survive its uninstall. Both are the population reportUnhydratableOrgScopedRows names at boot; S5 / C7 (feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211).
  9. Types. DeletePackageRequest loses organizationId and allTenants (an exported type of @objectstack/metadata-protocol); UninstallCleanup args and runUninstallCleanups's Pick drop organizationId. Both in-tree implementers (security.package-permissions in plugin-security, the runtime job cleanup) read { packageId } only, and cloud-connection's caller already passed { packageId, actor }; its local UninstallCleanupRunner narrows (a module-private type on a private member: emitted JS and the published d.ts unchanged). RIGHT; cloud (out of tree) NOT MEASURED.
  10. TENANT_SCOPE_REQUIRED leaves the ledger (the metadata-protocol row and the runtime PROVENANCE_WAIVERS entry): the ledger header's one surviving retirement ground holds — no producer left in packages/** at head (what remains is the protocol-17 entry, its registry and generated copies, one test header comment and this changeset); objectui 0 files at the pin (control sys_metadata 93); cloud NOT MEASURED. retired-error-codes.ts is the StandardErrorCode table only, so no prescription row is owed. RIGHT. NOT_OVERRIDABLE gains a row under @objectstack/service-automation: the credential move constructs the code itself on its failed[], so it is a second emitter ("listed once per emitting package"), not a door mirroring a thrown error — a row, not a waiver, is the right shape; check:error-code-provenance is a Lint & Repo Gates step (pending at the last read).
  11. Spec. organizationId leaves the SaveMetaItem, PublishMetaItem and DeleteMetaItem request schemas and authorable-surface/api.json loses the three lines with no tombstone — build-schemas.ts's vanished-key rule's NO route (the def is not reachable from a metadata-type root, which its check (c) recomputes from the Zod graph rather than taking the author's word), and Type Check · source gates (the leg that runs it) and Spec property liveness are success on this head. RIGHT as a route. ⚠ Precision, and it is what the six red pins measure: because these schemas are not .strict(), a request literal carrying organizationId is stripped silently at a spec parse; the entry's and the changeset's "refused 403 NOT_OVERRIDABLE" holds at the protocol (every door builds its request field by field, and the identity pin drives the protocol), not at the schema. The rewritten pins must state the contract the schema has — stripped at parse, refused at the protocol — not re-pin the key or assert a refusal the schema does not make.
  12. findPlatformScheduleOrgGaps and evaluateRuntimeAuthoringGate lose their organizationId input — internal to @objectstack/metadata-protocol (the entry re-exports SDUI_MANIFEST_SERVICE only), so the changeset's table row names a surface no consumer imports; harmless. Residue: protocol.ts:6276 still spreads evt.organizationId into the gate call (a spread, so no excess-property error; the gate ignores it). S5's tidy.
  13. Callers. The flow-credential move reports a legacy organization-scoped row on failed[] with NOT_OVERRIDABLE and logs at error with the consequence (the row still carries the credential in cleartext) and the fix (rotate; C7 carries the row) — the file's own existing catch shape for the same security property, and pinned with the control row moved. RIGHT; the ledger row in item 10 is its provenance. Seeds take no caller organization (applySeedBodies, applyPublishedSeeds); the loader derives the owner only under single (resolveSoleOrganizationId) and refuses a row that names none otherwise. RIGHT per D9 and D12 item 12 — the dev's Q3, answered A in ③. Precision: the loader's refusal still prescribes config.organizationId, which the package-publish path can no longer carry; its other remedy (organization_id on the record) is the live one. S5 / docs.
  14. Published text, sentence by sentence. The changeset, both entries (and their byte-equal registry.ts copies), the OS_METADATA_WRITABLE row of environment-variables.mdx and the DELETE /api/v1/packages/:id row of metadata-service.mdx each hold against the code at this head. content/docs/ui/public-data-collection.mdx §4 "Withdraw a public form" — untouched by this PR, byte-identical on 446c8b2a and at the head — states the contract this diff deletes: line 59 "An organization's copy can always withdraw the form for itself"; line 65 "Saving and publishing in an organization judges the organization's copy against the stored environment-wide definition it overrides …" (that is anonymousFormIntakeReopenRefusal, deleted here); line 71 "The organization-scoped save check judges every package's environment-wide definition of the name"; line 73 "The save check runs only when an organization's copy is saved or published … To close it, withdraw the form in that organization's copy too; the next organization-scoped save of a copy that keeps it open is refused." At this head no organization-scoped save or publish of any copy is accepted — 403 NOT_OVERRIDABLE before any read — so the capability line 59 asserts and the remedy line 73 prescribes are ones the runtime refuses, and the page's "two checks" are one. The standard this card applied on S2 (6074635359) and S3 (6080287167) — one published sentence a diff makes false fails the head — applies here unchanged. WRONG, and the FAIL ground beside the red shard. What on that page stays true: the endpoints' layered read (resolveFormBySlug / findPublicFormView, untouched; triage Q3 A), the environment-wide definition as the switch, a legacy organization copy's withdrawal still closing the form at the doors, and an environment withdrawal closing a form beneath an open legacy copy — both directions pinned in showcase-public-form-withdrawal-layers, which this PR re-premises to plant the legacy row at rest. The fix: rewrite §4's organization-layer paragraphs to that contract (an organization copy is a legacy row stored before ADR-0131 D6 that no write edits; the environment-wide definition is the one switch; C7 carries the legacy layer), drop the "save check" paragraphs, and declare the page to the docs lane with the two pages this PR already edits.

No other hand-written page names the deleted checks (grepped at head over content/docs excluding releases/ and references/); metadata-lifecycle.mdx:109 and :233, concept.mdx:441 and adding-a-metadata-type.mdx:45 describe the key's environment-overlay meaning or S3's door contract and are not made false. S3's carried items close here: domains/packages.ts threads no organization into any verb nor into assemblePackageManifest (item 6 of its ③), and protocol.ts no longer names organizationIdForMetaWrite (what remains is absence pins and history comments in five tests and the helper's own header).

② Semver level

.changeset/15206-protocol-environment-only.md: minor on @objectstack/metadata-protocol, @objectstack/runtime, @objectstack/service-automation and @objectstack/spec — all four published (private unset at head); a title with !; a BREAKING paragraph that names both directions; a FROM → TO table covering organizationId on the three request schemas and on every other write verb, the two deletePackage keys, the two DeletePackageRequest members, the UninstallCleanup argument, TENANT_SCOPE_REQUIRED and findPlatformScheduleOrgGaps; and "What a deployment observes" stated as the code pins it (legacy rows untouched by every write, skipped from the stored migration, not moved by the credential move, removed only by an uninstall). The level is right: Changesets is in pre mode (pre.json: mode pre, tag next), the fixed group is already majored by 22080-v18-line-opens.md (@objectstack/spec: major), S1 to S3 on this card are the precedent, and Check Changeset is success on this head. Owing nothing: @objectstack/cloud-connection (a module-private type on a private member; emitted JS and d.ts unchanged), @objectstack/objectql, @objectstack/rest and @objectstack/cli (tests only), @objectstack/dogfood (private). ADR-0087: one marker, registered metadata-write-organization-scope-refused, package-uninstall-environment-wide, in the gate's registered id[, id...] grammar; both entries exist under migrations/entries/semantic/18.*; registry.ts is +81 lines on the net diff, the two entries and nothing else, each byte-equal to its source; check:migration-registry is a Lint & Repo Gates step (pending at the last read). The entry kind is right: semantic/, because no authorable key is tombstoned (①11) and the uninstall keys are a protocol request type, not a spec one.

Clause-②: yes (narrowing) on PR body line 3, in the changeset, and on the claims 6082123637 (#15206) and 6082130422 (#22350) — matches the diff. yes: one accept-set widens — deletePackage({ packageId }) with neither key, refused 400 TENANT_SCOPE_REQUIRED before, is accepted and runs package-wide, and the dispatcher's DELETE /api/v1/packages/:id for an operator with no active organization, refused 400 before, proceeds. (narrowing), the BREAKING arm: three spec request keys, two protocol request keys, one exported type's two members, one cleanup argument, one ledger code and the whole per-organization write path leave. The arm beside a yes is the reader's own documented shape ("a diff that widens AND narrows", the clause2-line fixture), and the ADR-0087 gate reads it from the changeset. Right. One gap, folded into the patch round: the seed narrowing (①13 — a seed draft whose rows carry no organization_id, published by an org-active caller under group, was loaded into that organization and is now refused) is stated under "What changes" but has no FROM → TO row and is named in neither entry's surface; add the row (FROM: a seed relying on the publisher's active organization; TO: organization_id on each record, D12 item 12) and a clause in metadata-write-organization-scope-refused's surface.

③ Boundary flags

Dev deviations and questions (6085458127), each answered:

  • Size and the split (deviations[0]). Both measured reasons hold on the diff: coupled — on main, domains/packages.ts hands resolveActiveOrganizationId into nine /packages verbs, so the protocol refusal alone would answer 403 to every org-active caller's package write; and not under the line — packages/metadata-protocol alone is 3,843 changed lines (33 files; 1,582 source, 2,261 tests), over 3000. Over the PR, tests are 4,300 of 6,304 lines and test deletions 3,432 of the 4,802 deletions, so "most of the volume is deleted tests of deleted behaviour" holds. The split claim is RIGHT; the landing route (an authorized approval and the owning seat, or a human merge) is the seat's, not this record's.
  • Zone 2 not done — sys-metadata-repository.ts's organizationId option and getOverlayRepo → one environment repo: the protocol's reads still construct per-organization repositories, every write path uses getOverlayRepo(null), and the one per-organization write left is the uninstall removal (①8). Accepted: removing the option narrows reads, which is S5's. stored-migration.ts unchanged (report data): accepted.
  • Worktree, eight test-repair subagents, one OS_SKIP_DTS=1 build re-measured: process, accepted as reported — with the one consequence named in ①: the spec test suite was never run, and the head is red on it.
  • Q1 → A, confirmed. Ruling A's "every row bound to the package" is the width allTenants: true had; B narrows the uninstall to organization_id IS NULL, re-creating the protocol.deletePackage finds zero sys_metadata rows the data plane finds 3 of — uninstall leaves orphaned rows (persistence half of #7557) #7705 orphan on the one surviving door and leaving C7 to promote rows of a package that is gone; the claim's "no deletion of stored rows (C7)" names the migration and ceremony class (D10's fates), not an operator's uninstall. Carried: the two residue classes in ①8 (a create-closed type's legacy row refused NOT_CREATABLE; a legacy object row's table kept) — S5 / C7.
  • Q2 → A. The write verbs' scans are part of the write: copying a legacy body environment-wide is a silent promotion, rebinding a legacy row an organization-scoped write; the door's commit list and manifest read follow S3's rule and the claim's file surface, which names assemblePackageManifest. Carried to S5: the protocol's listCommits keeps its own organization branch, and the door's whole-timeline read now shows an org-active operator every organization's legacy commits (D7 makes the ledger deployment-level and manage_metadata is platform-scoped, so no wall is crossed) — S5 decides report-versus-serve for legacy commit rows as it does for legacy metadata rows.
  • Q3 → A. D9 ("a write on a tenant-column object with no organization is derived under single … and refused otherwise") and D12 item 12 ("Seeds under group must name their organization … or the load is refused"), verbatim on origin/main; the loader derives the owner only under single. B is a new key no measured caller pulls for. Carried: the loader's stale config.organizationId prescription (①13) and the changeset row (②).
  • Undeclared cross-lane paths the dev lists (packages/cloud-connection/src/marketplace-install-local-plugin.ts; packages/spec/src/stack.zod.ts, authorable-surface/api.json, src/api/protocol.test.ts; the runtime, objectql, rest, service-automation and dogfood tests; the two mdx pages): the seat declares them on the lane posts before the PR leaves draft — outside this review's inputs, so escalated, with content/docs/ui/public-data-collection.mdx added to the docs lane's list (①14).

New at this head:

  • The head is red on a required context, twice (Test Core (1/6) and (5/6), ①). The patch round (a) rewrites the six protocol.test.ts pins to the schema's actual contract (①11): the fixtures drop organizationId, the string-key loops drop it, and one case per schema pins what a request carrying the retired key gets at parse — the key stripped (and the protocol's refusal pinned where it lives, the identity pin), or a .strict() / tombstone refusal if the dev chooses to make the schema refuse; either way the entry's and the changeset's sentences must match the choice — and (b) re-premises audit-meta-item-org-scope.integration.test.ts to plant its two organization rows (and the audit rows it reads) at rest as legacy rows, the shape this PR's own re-premised suites use, leaving auditMetaItem's read for S5; that file is domain:cli's and joins the declaration list below. The report's measured set must then include the spec and runtime suites it names, or name them as NOT MEASURED.
  • origin/main moved under this review, and the move makes two generated artifacts stale on merge. At the first read origin/main was 446c8b2a (the PR's recorded base, 4 commits past the merge base, no file shared with this diff); by the end it was 4e9fe9ff6a — feat(spec)!: PROTOCOL_VERSION 17 → 18 in an ordinary PR — regenerated spec-changes.json and upgrade guide, ^18 handshakes, pre-mode lockstep exception (#22085 Q1 → B) #22215, PROTOCOL_VERSION 17 → 18, which regenerated packages/spec/spec-changes.json and docs/protocol-upgrade-guide.md so they now project the step-18 semantic entries (S1's and S3's ids read 2 to 4 hits each there; 0 on this head, whose PROTOCOL_VERSION is still 17). This PR adds two step-18 entries and, on its own tree, regenerates neither artifact — correctly, nothing projected them. Merged onto 4e9fe9ff6a both are stale, so check:spec-changes and check:upgrade-guide (Type Check · source gates, required) go red on the merged tree, while this head's own checks run against 446c8b2a and cannot see it. No textual overlap (feat(spec)!: PROTOCOL_VERSION 17 → 18 in an ordinary PR — regenerated spec-changes.json and upgrade guide, ^18 handshakes, pre-mode lockstep exception (#22085 Q1 → B) #22215 and this PR share no file; the local merge-tree exits 0 with a tree — read with the caveat that it honours the os-regen driver, which GitHub does not). This is AGENTS §10's jointly-wrong generated artifact, and it is why main must be merged before the landing: the patch round merges origin/main at or past 4e9fe9ff6a and runs check:generated --fix for the two. A new head, a new record.
  • PR body, cosmetic, for the seat's body write: "Verification (head 537fa89c8)" is one commit stale (7231c58 changes the changeset only, +2 / −2); "origin/main merged at e148ca98" is imprecise (the merge brought main to 2e10c9ab, the merge base); the "NOT MEASURED locally" list omits the spec test suite.

Out-of-scope findings, each carried: protocol.ts:6276's dead organizationId spread into the authoring gate (S5); the seed loader's refusal prescribing a config.organizationId the package-publish path cannot carry (S5 or domain:devx); metadata-lifecycle.mdx:109's D6 callout, true as written, could add that the protocol itself now refuses (S5); the runtime integration suites whose names still say org-scope now pin environment-wide behaviour (S5, churn); S3's objectui manage_org_presentation relay and the #15211 public-form pointer stand as recorded there; cloud's readers of TENANT_SCOPE_REQUIRED, DeletePackageRequest and UninstallCleanup NOT MEASURED (no checkout here; ADR-0131 §7 carries cloud).

Verdict grounds, all on this head: the required Test Core context is red on two shards — six spec pins this diff left on a key it retired, and seven runtime cases whose seed the refusal now refuses (①, first paragraph) — and one published page states a contract this diff deletes (①14). The code contract itself — the refusal, the ledgers, the uninstall, the types, the ledger code, the two entries and the changeset's level and arm — is judged sound above, so the patch round is the two test fixes, the page, the ② row, the main merge with its regeneration, and nothing else; a new commit on the branch is a new head and needs a new record.

Implemented-by: claude/issue-15206-s4-protocol-env-only
Reviewed-by: session_01Bw3y2DWhT9RPnrmDsNqEVG

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2318d0ba1602368c6096c7518aef8f0d02b3cc51
Local-runs: none

Inputs: card #15206 (body and all 38 comments, through the merge-main round 6089617413); #22350 (body and its 5 comments: the grade 6068216919, the ruling 6070750378, the claims 6071732822 and 6082130422, the release 6074820322); PR #22515 (body, its 93-file list, both comments — the docs-drift note and the earlier record 6085867875, FAIL on 7231c58fae — and the net diff against its merge base 5910b5e3e: 93 files, +1,639 / −4,857, equal to the file list); the head's check-runs, read twice (one Check Changeset run still in progress at the first read; at posting time 38 success, 4 skipped, 0 failure). Source at the head was read through the GitHub contents API (metadata-protocol/src/index.ts, scripts/pm/check-governed-merges.mjs, AGENTS.md, .changeset/pre.json, .objectui-sha, the dogfood withdrawal file); the compare of 7231c58fae...2318d0ba named the fix-round's own files. The sibling objectui checkout was grepped read-only at 2063f7a96, which is not the pin. Nothing was checked out, built, run or re-run.

① Derived judgments

Gate verdicts on this head, as read. Every required context is success: Lint & Repo Gates (the leg carrying check:migration-registry, check:error-code-provenance and check:adr-0087-registration), TypeScript Type Check and its four legs (· source gates runs check:authorable-surface, check:spec-changes, check:upgrade-guide and check:docs), Test Core with all six shards — (1/6), the packages/spec suite, and (5/6), the packages/runtime suite, were the two red shards of the FAIL record and are green here — Dogfood Regression Gate (rollup and 1/3 to 3/3), Build Core, Temporal Conformance (live PG + MySQL) and Governed Surface Queue Guard. Also green: Build Docs, Dogfood Verify CLI, Check Changeset (two runs on this sha, both success), Check PR Size, Spec property liveness, the docs-link and docs-flag checks and the four claim and closing guards. Skipped by contract: Console Pin Gate (the pin f0268ad784 is unchanged on this head and on main), Packed-tarball smoke (opt-in), and the body-edit twins of Auto Label / Check PR Size. Not governed: no path under .claude/, docs/adr/, skills/, docs/NORTH-STAR.md, AGENTS.md or CLAUDE.md; head repo is the base repo; draft, auto_merge unset, mergeable_state: clean. Size: 6,496 changed lines, over HUMAN_MERGE_LINE_THRESHOLD (3000, check-governed-merges.mjs line 1115 at this head and on main; the head's AGENTS.md §7(c) names the same figure) — the human-merge route; the route is the seat's, the split claim is judged in ③.

What changed since the FAIL record. The fix round's own commits touch eight files, none of them protocol, door or spec source: .changeset/15206-protocol-environment-only.md, content/docs/ui/public-data-collection.mdx, packages/spec/src/api/protocol.test.ts, packages/runtime/src/audit-meta-item-org-scope.integration.test.ts, the entry 18.metadata-write-organization-scope-refused.ts (its surface clause), and the regenerated registry.ts, spec-changes.json and docs/protocol-upgrade-guide.md; the merge-main round regenerated the last two again on the merged tree. protocol.ts, domains/packages.ts, protocol.zod.ts, error-code-ledger.zod.ts, runtime-authoring-gate.ts, flow-credential-migration.ts, marketplace-install-local-plugin.ts and the second entry are byte-identical to the head the FAIL record judged. Every item below was re-read on this head's diff all the same.

Accept-set and public-surface changes, each read off the net diff and judged.

  1. One refusal, asked FIRST, on every write verb. organizationScopedWriteRefusal is asked before any read in saveMetaItem (draft and publish), publishMetaItem, deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata, through refuseOrganizationScopedWrite(subject, request), which reads organizationId off whatever arrived (requestedOrganization) because the key is gone from every verb's declared request. 403 NOT_OVERRIDABLE for every type, no new code; undefined, null and '' read as no organization; the first sentence names the posture (resolveTenancyPosture, with an unrecognized-posture fallback). The five-type allowOrgOverride exemption, the static-registry carve-out for plugin-registered types and the OS_METADATA_WRITABLE arm are gone. RIGHT: card item (2), the stage plan's S4 row, D6. Pinned by the identity pin, enumerated over DEFAULT_METADATA_TYPE_REGISTRY plus acme_widget, both save modes, the per-item promotion, the package publish, the hatch, topology, the three posture sentences, a legacy organization draft left as stored, and the environment-wide controls; the dev's reverse verification (17 red / 285 green with the exemption re-inserted, restored blob-equal) is the recorded direction.
  2. Deleted as unreachable or per-organization: orgScopedWriteRefusal, anonymousFormIntakeOrgScopeRefusal, anonymousFormIntakeReopenRefusal, envWideRawViewRows, resolveMetaItemOrgScope, resolveDraftOrgScopeForPublish; the three anonymousFormIntake* imports leave protocol.ts; the per-draft and per-item scope threading leaves publish, promote, discard, revert, rollback, duplicate and adopt-orphans; lockWriteRefusal, assertLockAllowsWrite, assertLockAllowsDelete, saveConflict and the conflict audit lose their organization inputs; viewContainerNameCollisionRefusal reads environment siblings. RIGHT. The page that described the two anonymous-form refusals is now rewritten (item 14).
  3. The audit and commit ledgers write organization_id NULL. The module-private MetadataAuditEntry.organizationId is typed null, every recordMetadataAudit call passes null, recordPackageCommit loses orgId. RIGHT (D7).
  4. The package verbs see environment drafts only. publishPackageDrafts, discardPackageDrafts and revertStoredPackage list through getOverlayRepo(null) (packageScopedRowWhere(null, …) is organization_id IS NULL), so a legacy organization draft is never promoted, discarded or reverted by them; discardDraftInItsScope loses its scope. RIGHT, pinned in the identity control and protocol-publish-drafts-org-scope.
  5. revertCommit refuses a commit row recorded in a legacy organization layer (same code, its own remedy sentence), every item reverts environment-wide and the revert commit is recorded environment-wide; rollbackToPackageCommit plans from listCommits({ packageId }), the whole timeline, so a legacy organization commit in the path lands in failed[] with success false — loud, never silent. RIGHT. The two deleted runtime suites (Four more strict organization_id equalities left in protocol.ts — two measured (revertCommit / rollbackToPackageCommit), two unverified (duplicatePackage / reassignOrphanedMetadata) #7819 tier 1, revertCommit attributes its revert commit to the request's organization even when the commit it reverted was env-wide — newly reachable as of #7819 tier 1 #7860) pinned the organization resolution this diff removes; the environment-wide rollback stays pinned in package-list-commits-org-scope.
  6. duplicatePackage and reassignOrphanedMetadata scan organization_id IS NULL. No legacy organization body is copied environment-wide under a new package, no legacy row is rebound. RIGHT — the dev's Q2, answered A in ③.
  7. migrateStoredMetadata reports an organization-scoped row skipped naming C7 and never re-saves it; applyRemoteMetadataMutation converges on the environment row whatever the event names. RIGHT.
  8. deletePackage ([decision] once ADR-0131 C5 makes package metadata environment-wide, does deletePackage's organization-scope guard (organizationId / allTenants, TENANT_SCOPE_REQUIRED, #7780) retire? #22350 A). A request carrying organizationId or allTenants — present with any value, false and undefined included (hasOwnProperty) — answers 400 INVALID_REQUEST before any read; with neither key the where is { package_id } alone, so every row bound to the package in this environment is removed; a legacy organization row goes through removeLegacyOrganizationRowOnUninstall (the repository's delete, intent runtime-only, history tombstone kept, an audit row with organization_id NULL and a note naming the organization) — the one organization-scoped write left in the class, reachable from deletePackage alone and only as a removal. The door: requireUninstallOrganizationScope and its 400 TENANT_SCOPE_REQUIRED are deleted; requireManageMetadata and the read-only-package gate stay; deletePackage is handed { packageId, keepData? }. Ruling fidelity: the ruling's "refused by the strict request body" is realised as the verb's own key check, because DeletePackageRequest is a @objectstack/metadata-protocol type with no spec schema to make strict (the spec half that exists — the ledger rows and the semantic entry — is here); the effect the ruling names (a request still carrying a retired key is refused, with an ADR-0087 entry stating retirement and remedy) holds. This is the one widening of the accept set: deletePackage({ packageId }), and the dispatcher's DELETE /api/v1/packages/:id for an operator with no active organization, refused 400 TENANT_SCOPE_REQUIRED before, now proceeds package-wide. Pinned on both sides: the unit suite (four key shapes refused, nothing removed, the registry untouched), the runtime integration suite (five key shapes refused with the seed untouched; neither key removes 5 of 5 including both legacy organization rows and leaves the other package alone), and the door test (deleteRequests equal to [{ packageId }] for a member, a removed member and an org-less caller, each 200). RIGHT, as ruling 6070750378 decides. Two residue classes, carried: assertAllowed admits a runtime-only delete for overlay-allowed, runtime-creatable and plugin-registered types, so a legacy organization row of a create-closed static type would be refused into failed[] with success false — loud; and the legacy path never passes dropStorage, so a hatch-written organization-scoped object row's table would survive its uninstall. Both are the population reportUnhydratableOrgScopedRows names at boot; S5 / C7 (feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211).
  9. Types. DeletePackageRequest loses organizationId and allTenants; UninstallCleanup args and runUninstallCleanups's Pick drop organizationId — all three exported from the @objectstack/metadata-protocol entry (index.ts lines 121 and 126 at this head), so a published narrowing, declared in the changeset. Both in-tree implementers read { packageId } only; cloud-connection's module-private UninstallCleanupRunner narrows on a private member (emitted JS and its published d.ts unchanged). RIGHT; cloud (out of tree) NOT MEASURED.
  10. TENANT_SCOPE_REQUIRED leaves the ledger (the metadata-protocol row and the runtime PROVENANCE_WAIVERS entry), so the published ErrorCode enum narrows by one (the regenerated contract.mdx reads "+321 more"): no producer is left in packages/** at this head — what remains is the protocol-17 entry, its registry and generated copies, one test header comment and this changeset; objectui 0 hits at the sibling checkout and, per the dev, 0 at the pin; cloud NOT MEASURED. retired-error-codes.ts is the StandardErrorCode table only, so no prescription row is owed. NOT_OVERRIDABLE gains a row under @objectstack/service-automation — the credential move constructs the code itself on its failed[], a second emitter, so a row and not a waiver is the right shape; no new code enters the union. check:error-code-provenance is a Lint & Repo Gates step, success. RIGHT.
  11. Spec. organizationId leaves the SaveMetaItem, PublishMetaItem and DeleteMetaItem request schemas; authorable-surface/api.json loses the three lines with no tombstone — the vanished-key NO route (the def is not reachable from a metadata-type root), and Type Check · source gates and Spec property liveness are success. The schemas are not .strict(), so a request literal carrying organizationId is stripped at a spec parse and refused at the protocol. The six pins the FAIL record named are rewritten to that contract: each "preserves every member" fixture drops the key, each optional-string loop drops it, each schema gains one case pinning the key as STRIPPED at parse (success true, the key absent from the parsed value, the protocol's refusal pointed at by name), and each typed-literal block gains a @ts-expect-error on organizationId — a directive that TypeScript Type Check (success) proves is not unused, so the type really lost the key. RIGHT. The changeset row and the entry's replacement say "stripped at a spec parse and refused at the protocol", which matches.
  12. findPlatformScheduleOrgGaps and evaluateRuntimeAuthoringGate lose their organizationId input — internal to the package (the entry re-exports SDUI_MANIFEST_SERVICE only from that module, read at this head), so the changeset's table row names a surface no consumer imports; harmless. Residue: protocol.ts still spreads evt.organizationId into the gate call (a spread, so no excess-property error; the gate ignores it). S5's tidy.
  13. Callers. The flow-credential move reports a legacy organization-scoped row on failed[] with NOT_OVERRIDABLE, re-saves nothing, and logs at error with the consequence (the row still carries the credential in cleartext) and the fix (rotate; C7 carries the row); pinned with the control row moved and the hook string absent from every log line. RIGHT. Seeds take no caller organization (applySeedBodies, applyPublishedSeeds); the loader derives the owner only under single and refuses a row that names none otherwise. RIGHT per D9 and D12 item 12 — the dev's Q3, answered A in ③. Precision carried: the loader's refusal still prescribes config.organizationId, which the package-publish path can no longer carry; its other remedy (organization_id on the record) is the live one. S5 / docs.
  14. Published text, sentence by sentence, against the code at this head. The changeset (every section, the seed row included), both entries and their byte-equal registry.ts copies, the OS_METADATA_WRITABLE row of environment-variables.mdx and the DELETE /api/v1/packages/:id row of metadata-service.mdx each hold. content/docs/ui/public-data-collection.mdx §4 — the FAIL ground — is rewritten, and every sentence now holds: the layered read of the anonymous endpoints (the Default Organization's legacy copy preferred for the body, a withdrawal in either layer closing the form, fail-closed) is S3's verified reading of resolveFormBySlug and findPublicFormView, which this PR does not touch; "no write can edit it now: every organization-scoped save or publish is refused with 403 NOT_OVERRIDABLE" is item 1; "its withdrawal still closes the form" and "a form a legacy copy withdraws stays closed" are the dogfood file's starred case; "an environment-wide withdrawal closes the form even beneath an open legacy copy" is its second case; the ceremony sentence is decision: ADR-0131 C5 — 17.x honours a public form's withdrawal saved at the organization layer. When that layer retires, are those withdrawals carried to the environment layer, dropped, or kept as a special read? #22008 A / C7; the "What counts as a withdrawal", "Which form a withdrawal closes", "Forms a package ships" and "Known limit: packages and names" paragraphs keep the endpoints' matching rule the old page stated with the organization-save clauses removed; "Known limit: legacy copies" restates the old endpoint limit without the save-check remedy. No sentence left on the page names a capability or a refusal the runtime does not deliver. RIGHT. The dogfood file plants its legacy rows at rest through the engine under a system context, the shape every re-premised suite in this PR uses. Generated artifacts: spec-changes.json and docs/protocol-upgrade-guide.md now project both step-18 ids (2 and 1 hits each, matching the dev's census), regenerated on the merged tree whose base 5910b5e3e carries PROTOCOL_VERSION 18; check:spec-changes and check:upgrade-guide are success on this head.
  15. audit-meta-item-org-scope.integration.test.ts (auditMetaItem's organizationId is dead on both ends — the audit read returns every org's rows for a (type, name), while its comment describes a scope filter that is not in the query #8747) is re-premised as the FAIL record prescribed: the two organization-scoped rows and the audit rows that recorded them are planted at rest through engine.insert under a system context (plantLegacyOrgSave, three call sites, the seventh save in the "different item" case included), the environment row is still written by saveMetaItem, and the read assertions — S5's — are unchanged. Test Core (5/6) is success. RIGHT; the file is domain:cli's and is in the declaration list below.

S3's carried items stay closed here (no organization threaded into any /packages verb nor into assemblePackageManifest; organizationIdForMetaWrite survives only in absence pins and history comments). No other hand-written page names the deleted checks; metadata-lifecycle.mdx:109, concept.mdx and adding-a-metadata-type.mdx describe the key's environment-overlay meaning or S3's door contract and are not made false.

② Semver level

.changeset/15206-protocol-environment-only.md: minor on @objectstack/metadata-protocol, @objectstack/runtime, @objectstack/service-automation and @objectstack/spec — all four published; a title with !; a BREAKING paragraph naming both directions; a FROM → TO table covering organizationId on the three request schemas (stripped at parse, refused at the protocol) and on every other write verb, the two deletePackage keys, the two DeletePackageRequest members, the UninstallCleanup argument, TENANT_SCOPE_REQUIRED, findPlatformScheduleOrgGaps, and — the row the FAIL record asked for — the seed draft relying on the publisher's active organization under group; and "What a deployment observes" stated as the code pins it. The level is right: Changesets is in pre mode (pre.json at this head: mode pre, tag next), the fixed group is already majored by 22080-v18-line-opens.md, S1 to S3 on this card are the precedent, and Check Changeset is success on this head, twice. Owing nothing: @objectstack/cloud-connection (a module-private type on a private member), @objectstack/objectql, @objectstack/rest and @objectstack/cli (tests only), @objectstack/dogfood (private). ADR-0087: one marker, registered metadata-write-organization-scope-refused, package-uninstall-environment-wide, in the gate's grammar; both entries exist under migrations/entries/semantic/18.*, and the entry's surface now carries the seed clause; registry.ts on the net diff is the two entries and nothing else, each byte-equal to its source; check:adr-0087-registration and check:migration-registry are Lint & Repo Gates steps, success. The entry kind is right: semantic/, because no authorable key is tombstoned (①11) and the uninstall keys are a protocol request type, not a spec one.

Clause-②: yes (narrowing) on PR body line 3, in the changeset, and on the claims 6082123637 (#15206) and 6082130422 (#22350) — matches the diff. yes: one accept set widens — deletePackage({ packageId }) with neither key, and the dispatcher's DELETE /api/v1/packages/:id for an operator with no active organization, refused 400 before, are accepted and run package-wide (①8). (narrowing), the BREAKING arm: three spec request keys, two protocol request keys, one exported type's two members, one cleanup argument, one ledger code, the seed's caller-organization fallback and the whole per-organization write path leave. The arm beside a yes is the reader's documented shape for a diff that widens and narrows, and the ADR-0087 gate reads it from the changeset. Right.

③ Boundary flags

Dev deviations and questions, from the S4 report 6085458127, the patch round 6087716647 (open_questions: []; one self-inflicted edit slip restored byte-for-byte before commit) and the merge-main round 6089617413, each answered:

  • Size and the split (deviations[0]). Both measured reasons hold on this diff: coupled — on main, domains/packages.ts hands resolveActiveOrganizationId into nine /packages verbs, so the protocol refusal alone would answer 403 to every org-active caller's package write; and not under the line — packages/metadata-protocol alone is over 3,800 changed lines (33 files), over 3000. Over the PR, test files carry most of the volume and most of the deletions are tests of deleted behaviour. The split claim is RIGHT; the landing route (an authorized APPROVED review by a GOVERNED_APPROVERS account and then the owning seat, or a human merge) is the seat's, not this record's.
  • Zone 2 not done (sys-metadata-repository.ts's organizationId option, getOverlayRepo to one environment repository, stored-migration.ts): accepted — the protocol's reads still construct per-organization repositories, every write path uses getOverlayRepo(null), the one per-organization write left is the uninstall removal (①8), and removing the option narrows reads, which is S5's.
  • Worktree, eight test-repair subagents, the OS_SKIP_DTS=1 build re-measured: process, accepted as reported. The consequence the FAIL record named — the spec suite never run — is closed: the patch round and the merge-main round measured the full packages/spec suite, and the runtime suite was measured in the patch round; the merge-main round's "runtime NOT MEASURED: the merge touched no runtime file" is accepted, because Test Core (5/6) on this head measured it.
  • Q1 → A, confirmed. Ruling A's "every row bound to the package" is the width allTenants: true had; B narrows the uninstall to organization_id IS NULL, re-creating the protocol.deletePackage finds zero sys_metadata rows the data plane finds 3 of — uninstall leaves orphaned rows (persistence half of #7557) #7705 orphan on the one surviving door and leaving C7 to promote rows of a package that is gone; the claim's "no deletion of stored rows (C7)" names the migration and ceremony class, not an operator's uninstall. Carried: the two residue classes in ①8.
  • Q2 → A. The write verbs' scans are part of the write: copying a legacy body environment-wide is a silent promotion, rebinding a legacy row an organization-scoped write; the door's commit list and manifest read follow S3's rule and the claim's file surface. Carried to S5: the protocol's listCommits keeps its own organization branch, and the door's whole-timeline read now shows an org-active operator every organization's legacy commits (D7 makes the ledger deployment-level and manage_metadata is platform-scoped, so no wall is crossed).
  • Q3 → A. D9 and D12 item 12, verbatim on main; the loader derives the owner only under single. B is a new key no measured caller pulls for. Carried: the loader's stale prescription (①13); the changeset row is now present (②).
  • Undeclared cross-lane paths — the dev's original list (packages/cloud-connection/src/marketplace-install-local-plugin.ts; packages/spec/src/stack.zod.ts, authorable-surface/api.json, src/api/protocol.test.ts; the runtime, objectql, rest, service-automation and dogfood tests; the two mdx pages) plus the patch round's three additions (packages/runtime/src/audit-meta-item-org-scope.integration.test.ts, domain:cli; content/docs/ui/public-data-collection.mdx, domain:devx; the regenerated docs/protocol-upgrade-guide.md and packages/spec/spec-changes.json, domain:spec): the seat declares them on the lane posts before the PR leaves draft — outside this review's inputs, so escalated.

New at this head, for the seat (none a verdict ground):

  • origin/main is four commits past this head's merge base (40a6ee50a, 26bf56fee, d303b3e7a, faf634850). fix(spec): a value-slot remedy reads a CEL-claimed head through vars (list.0 → vars["list"][0]) #22524 adds the semantic entry 18.flow-value-slot-template-dialect-refused and regenerates packages/spec/src/migrations/registry.ts, packages/spec/spec-changes.json, docs/protocol-upgrade-guide.md and content/docs/references/api/protocol.mdx — four generated files this PR also changes. No hand-written file overlaps (service-automation's engine.ts/index.ts/plugin.ts against this PR's flow-credential-migration.ts; runtime's automation.ts/action-execution.ts against domains/packages.ts), and GitHub reads the PR as mergeable, but a text merge of a generated artifact is not a regeneration (AGENTS.md Multi-agent discipline §10 and §11), and this PR lands on the human-merge route, not through the queue's rebuilt generation. Before the landing the seat merges origin/main through scripts/pm/os-regen-merge.sh and runs check:generated --fix; a head that differs from this one by that regeneration alone stays under this record (the pure-regeneration exception, with a Regen-provenance: line on the PR); any other change is a new head and a new record.
  • PR body, cosmetic, for the seat's body write: the "Size" paragraph reads 89 files, +1,502 / −4,802; at this head it is 93 files, +1,639 / −4,857 (6,496); the "Not declared" list omits the patch round's three additions named above.

Out-of-scope findings, each carried, one line: protocol.ts's dead organizationId spread into the authoring gate (S5); the seed loader's refusal prescribing a config.organizationId the package-publish path cannot carry (S5 or domain:devx); metadata-lifecycle.mdx:109's D6 callout, true as written, could add that the protocol itself now refuses (S5); the runtime integration suites whose names still say org-scope now pin environment-wide behaviour (S5, churn); cloud's readers of TENANT_SCOPE_REQUIRED, DeletePackageRequest and UninstallCleanup NOT MEASURED (no checkout here; ADR-0131 §7 carries cloud); the objectui pin f0268ad784 is unchanged on this head and on main, and the sibling checkout read here (2063f7a96, not the pin) has 0 hits for allTenants and TENANT_SCOPE_REQUIRED, agreeing with the dev's reading at the pin.

Verdict grounds, all on this head: both FAIL grounds of 6085867875 are closed — the required Test Core context is green on all six shards, with the six spec pins rewritten to the schema's real contract (①11) and the runtime audit read re-premised at rest (①15), and the one published page that stated the deleted contract is rewritten so that every sentence holds (①14) — the main merge with its regeneration landed, the seed row is in the changeset and the entry, and the code contract (the refusal, the ledgers, the uninstall, the types, the ledger code, the two entries, the changeset's level and arm) is judged sound above. The remaining items are the seat's landing steps and declarations, not defects in the diff.

Implemented-by: claude/issue-15206-s4-protocol-env-only
Reviewed-by: session_01Bw3y2DWhT9RPnrmDsNqEVG

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

domain:engine#2 · session_01Bw3y2DWhT9RPnrmDsNqEVG · 2026-10-09T22:26Z · 复核记录是本 PR 的 6090242635(PASS),ACCEPT 在 #15206 的 6090274140。

改了什么:这是 ADR-0131 C5 的 S4 阶段,同时落实 #22350 的裁决 A。

  • 元数据协议的每一个写操作,都在任何读取之前先拒绝带组织的写入,返回 403 NOT_OVERRIDABLE。这包括保存、发布、删除、回滚、回退提交,以及包的发布、丢弃草稿、回退、复制和认领孤儿。按组织写入的那条路径整条删掉。
  • 审计和提交台账写入的 organization_id 一律为空。
  • 卸载包不再接受 organizationId / allTenants,带了任何一个就返回 400。两个都不带时,在整个环境里删掉绑定这个包的所有行,包括以前按组织存下的存量行。
  • spec 的三个请求 schema 去掉了 organizationId:解析时这个键会被丢弃,真正的拒绝发生在协议层。

为什么改:ADR-0131 D6 规定,环境元数据属于整个部署,任何组织都不再有自己的一份。S3 已经让 /meta 入口不再带组织。S4 让协议本身也拒绝,所以 /packages、存量迁移、插件等所有入口的答案都一样。如果卸载还按组织来做,就会在唯一剩下的这个入口上,重新制造 #7705 那种孤儿行。

风险与代价(含回滚):

  • 这是破坏性变更。 还在按组织写元数据的调用方会被拒绝,包括依赖发布者当前组织的种子数据。changeset 里给了完整的「原写法 → 新写法」对照表。
  • 影响面。 objectui 在锁定版本上没有任何一处用到。cloud 侧的读者没有测量,按 ADR-0131 §7 归 cloud 负责。
  • 存量组织行原样保留,只有卸载时会删掉所属包的行。读取侧仍然会返回它们,读侧收窄在 S5,迁移在 C7。
  • 规模。 共 6,496 行,大部分是删掉的、针对已删除行为的测试。拆分不可行:/packages 入口必须和协议的拒绝同时落地;而且光是协议包就超过 3,000 行。
  • 回滚。 revert 这一个 PR 即可。存量数据既不迁移也不删除,所以不需要修数据。唯一例外:合入期间被卸载删掉的包行,revert 之后不会回来。

席位意见:建议批准。

你要做的:用 os-zhuang 或 hotlong 对本 PR 提交一次 Approve,或者亲手合入。

@os-zhuang
os-zhuang marked this pull request as ready for review October 10, 2026 00:02
@os-zhuang
os-zhuang enabled auto-merge October 10, 2026 00:03
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit b389e43 Oct 10, 2026
55 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-15206-s4-protocol-env-only branch October 10, 2026 00:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants