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

Draft
objectstack-fleet[bot] wants to merge 19 commits into
mainfrom
claude/issue-15206-s4-protocol-env-only
Draft

objectstack-fleet[bot] wants to merge 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).

Not in this PR (by ruling)

Cross-lane paths

  • domain:cli: packages/runtime/src/domains/packages.ts (declared) and runtime tests.
  • domain:services: packages/services/service-automation/src/flow-credential-migration.ts (declared) and its test.
  • domain:spec:
    • packages/spec/src/api/protocol.zod.ts, error-code-ledger.zod.ts and stack.zod.ts (comment);
    • migrations/entries/semantic/18.* (2 new);
    • migrations/registry.ts, authorable-surface/api.json and src/api/protocol.test.ts;
    • content/docs/references/api/* (generated). All of these are declared.
  • Not declared:
    • packages/cloud-connection/src/marketplace-install-local-plugin.ts (one type line; a consumer of DeletePackageRequest the census missed);
    • test files in packages/objectql, packages/rest and packages/qa/dogfood;
    • content/docs/deployment/environment-variables.mdx and content/docs/kernel/contracts/metadata-service.mdx.

Size

About 6,300 changed lines (+1,502 / −4,802, 89 files), over the human-merge line (3,000 on main at PR time). 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 (head 537fa89c8, origin/main merged at e148ca98)

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 (declared to CI):

  • the full runtime, 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
…d write (ADR-0131 D6)

Claude-Session: https://claude.ai/code/session_01NcYr731pAx356wDedHe9Ca
Co-authored-by: Claude <noreply@anthropic.com>
…est keys, ADR-0087 entries

Claude-Session: https://claude.ai/code/session_01NcYr731pAx356wDedHe9Ca
Co-authored-by: Claude <noreply@anthropic.com>
…stry regenerated

Claude-Session: https://claude.ai/code/session_01NcYr731pAx356wDedHe9Ca
Co-authored-by: Claude <noreply@anthropic.com>
…e retired organizationId

Claude-Session: https://claude.ai/code/session_01NcYr731pAx356wDedHe9Ca
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NcYr731pAx356wDedHe9Ca
Co-authored-by: Claude <noreply@anthropic.com>
…e orphan-adoption double unfiltered

Claude-Session: https://claude.ai/code/session_01NcYr731pAx356wDedHe9Ca
Co-authored-by: Claude <noreply@anthropic.com>
@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

claude added 2 commits October 9, 2026 17:30
…audit read's seed; public-form withdrawal page; seed narrowing row

Claude-Session: https://claude.ai/code/session_01NcYr731pAx356wDedHe9Ca
Co-authored-by: Claude <noreply@anthropic.com>

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

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants