Skip to content

feat(metadata-protocol)!: managed content is sealed — OS_METADATA_WRITABLE no longer opens an item a managed package ships (ADR-0131 D6, #15206 S2) - #22401

Draft
objectstack-fleet[bot] wants to merge 8 commits into
mainfrom
claude/issue-15206-s2-managed-seal
Draft

objectstack-fleet[bot] wants to merge 8 commits into
mainfrom
claude/issue-15206-s2-managed-seal

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Refs #15206 (S2)
Clause-②: no

Stage S2 of #15206: managed content is sealed (ADR-0131 D6, regime C). The operator hatch OS_METADATA_WRITABLE, and its legacy spelling OBJECTSTACK_METADATA_WRITABLE, no longer opens an overlay write onto an item a managed package ships, and no longer opens a removal of one. Such a request now answers 403 NOT_OVERRIDABLE, and the refusal's first sentence names "a managed package". These stay as they were:

  • disabling a managed flow or action (operator-gated, under its wall);
  • cloning a flow under a new name, which records no linkage;
  • creating a new flow in Studio (the positive control).

Declared test change outside the card surface: packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts (declared to domain:services on #6021). Its two hatch-OPEN cases now pin that the protocol's package door answers, not the lock; see Patch round 1.

The mechanism (M3: one predicate)

isSealedManagedItem(type, name) holds when the item is artifact-backed and its type's registry entry opens no overlay channel. The registry half is registryAllowsOverlay(type), which is isOverlayAllowed without the environment variable. These all read this one predicate:

  • the save door (refusePackagedBaseOverride);
  • the removal door (refusePackagedBaseRemoval);
  • the read envelope (packagedBaseRefusal, which sets editable / deletable).

The /automation definition doors ask the same protocol doors.

The repository (SysMetadataRepository.assertAllowed) applies the same rule one layer down. With the hatch open, only intent: 'runtime-only' (a new item no package ships) passes. An override-artifact intent gets the sealed sentence; that covers draft promotion, restore and revert.

For a shipped item, two other places no longer consult the hatch: the code-only check in saveMetaItem, and the repository route in deleteMetaItem.

The sentence comes from one builder, managedItemSealedSentence in packaged-base-regime.ts. It is internal and not reachable from the package entry.

  • A type with an ADR-0126 regime row (flow, action, permission) keeps naming its sanctioned route: the clone or the switch.
  • Every other type says it is sealed, says that the hatch does not open a managed item, and keeps the source remedy.

SysMetadataRepository.readOnlyBaseOverrideError drops its third parameter, hatchOpen, because nothing it chose survives the seal.

Built entry declarations (dist/index.d.ts), measured. Nothing widens:

  • two new private members: private static registryAllowsOverlay; and private isSealedManagedItem;;
  • static readOnlyBaseOverrideError(type: string, packageId: string): Error loses its optional third parameter, which narrows it; no caller outside the package exists (git grep);
  • managedItemSealedSentence is not exported.

Door table (M1, M2, M4, M5), measured on a real boot

Setup:

  • The CRM example, booted through bootStack(crmStack, { automation: true }).
  • Base is 8311650228. Head is this branch.
  • Two transports: REST is the RestServer /api/v1/meta routes; DISP is the runtime HttpDispatcher.
  • Hatch modes:
    • OS: OS_METADATA_WRITABLE=flow,object,field,permission,position.
    • LEGACY: the same value under OBJECTSTACK_METADATA_WRITABLE.
    • NONE: no hatch.
    • JOB: OS_METADATA_WRITABLE=job.

The two transports read the same unless a row notes otherwise. The dispatcher serves no DELETE on /meta items: it answers 405 METHOD_NOT_ALLOWED in every mode, at base and at head. The DELETE rows are therefore REST.

Probe OS base OS head LEGACY base LEGACY head NONE and JOB (base = head)
PUT /meta/flow/crm_convert_lead_wizard (shipped) 200 403 NOT_OVERRIDABLE 403 403 403
PUT /meta/object/crm_lead (field relabel) 200 403 403 403 403
PUT /meta/permission/crm_sales_user 403 (plugin-security lock) 403 (protocol door) 403 403 403
PUT /meta/position/sales_rep (M5) 200 403 403 403 403
PUT /automation/crm_convert_lead_wizard 200 403 403 403 403
DELETE /meta/flow over a legacy overlay row 200 403 403 403 403
DELETE /meta/flow with no row 200 403 403 403 403
DELETE /meta/object/crm_lead over a legacy row 200 403 403 403 403
DELETE /meta/object/crm_lead?dropStorage=true 200, and the object leaves the data plane (POST /data/crm_lead 404 OBJECT_NOT_FOUND, GET 500 DATABASE_ERROR) 403, and the data plane stays up (201 / 200) not run not run not run
DELETE /meta/permission, /meta/position over a legacy row 200 200 (the #6960 repair, kept) 200 200 200
DISP envelope of the shipped flow, editable / deletable true / true false / false true / true false / false false / false
/meta/types entry for flow, allowOrgOverride / overrideSource true / env true / env (Q2) true / env true / env false / registry

Controls, measured in every mode, on both transports, with base equal to head:

  • PUT of a view overlay (crm_opportunity.all): 200. Its REST DELETE: 200.
  • PUT /meta/flow/NEW: 200.
  • POST /automation (create): 200.
  • Toggle off, then on: 200 / 200.
  • Clone: 200, and the clone carries no linkage keys.
  • PUT /meta/job/NEW: 403 NOT_CREATABLE under NONE, OS and LEGACY; 200 under JOB, unchanged (M6, out of scope, below).

M2: the two hatch readers diverge on the legacy spelling. This is measured.

  • The protocol's reader, envWritableTypes(), reads both spellings through readEnvWithDeprecation.
  • The repository's reader, envWritableMetadataTypes(), reads OS_METADATA_WRITABLE only.

At base, the legacy spelling advertised managed items as writable, while every write onto them answered 403:

  • the listing read allowOrgOverride: true;
  • the DISP envelope read editable: true;
  • every write answered 403, because the repository's hatch never opened.

At head the seal makes both readers irrelevant for managed items, and the envelope reads false under both spellings. For creating an item no package ships, the divergence remains, and it is reported below.

M4. This PR does not touch the toggle path or its gate. The gate is measured by automation-activation-posture-gate.test.ts and action-activation-posture-gate.test.ts: 2 files, 46 passed. In the group and isolated postures a tenant admin is refused and the operator is allowed; enable is gated as well as disable; the clone door is not gated. In the single posture the gate is inert, which is why the CRM boot reads 200 on the toggle. As measured above, the clone carries no linkage, and a new flow and POST /automation answer 200. M5. A position overlay through the hatch is refused at save time (the table above).

Pins

New:

  • packages/rest/src/rest-meta-managed-seal-hatch.test.ts, 6 cases. The REST door relays the seal for flow, object, field, permission and position, under both spellings and both kernel shapes. Each answer is byte-equal to the hatch-shut answer and names "managed package". It also covers DELETE of a flow and an object.
  • packages/runtime/src/meta-managed-content-seal.test.ts, 12 cases. It drives the real HttpDispatcher, protocol and repository:
    • a PUT is refused and writes no row;
    • the GET envelope reads editable / deletable false;
    • controls: a view overlay and a new flow answer 200.
  • packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts, 10 cases, on CRM under the OS hatch:

Re-premised: the existing pins that carried "the hatch opens a managed item" now pin the seal. They are in metadata-protocol (10 files), objectql (5), rest (3), runtime (2), and the dogfood showcase scalar-divergence file. That file now seeds its pre-seal rename as a legacy row and cold-boots, so its read assertions keep a premise.

Patch round 1 adds two more: the plugin-security lock-gate cases, and #22365's cold-boot catalog control (below).

Reverse verification, at c2d18e52f5, under a trap restore

Two mutations reopen the hatch for managed items:

  • In the protocol, registryAllowsOverlay also reads envWritableTypes().
  • In the repository, if (hatchOpen && intent === 'runtime-only') return; becomes if (hatchOpen) return;.

Both landed on disk (ablation-replace: anchor 1 → 0, blob changed). Both reached dist/: the preflight found the marker in 2 built files.

Suite Mutated Restored
metadata-protocol seal pins 13 failed / 72 passed 85 / 85
rest 6 failed / 6 6 / 6
runtime 8 failed / 4 passed (the 4 passing are the controls) 12 / 12
dogfood 4 failed / 6 passed (the 6 passing are the premise, #6960, view, new flow, toggle and clone) 10 / 10

The restore was proved three ways:

  • each blob equals HEAD (6df9a994bc36 and 690b710cc415);
  • git diff HEAD is empty and the working tree is clean;
  • after a rebuild, --absent passed for both markers.

The direction was an ordinary red.

Changeset and ADR-0087

The changeset is .changeset/15206-managed-content-sealed.md: @objectstack/metadata-protocol minor, with the BREAKING paragraph for the v18 prerelease line. Changesets is in pre mode.

The ADR-0087 marker is not-required (no-migration-prescription): no spec key, stored shape or export changes, and every stored row loads and serves unchanged. check:adr-0087-registration reads it as [BREAKING+bang+clause-②-narrowing] not-required.

The OS_METADATA_WRITABLE row in content/docs/deployment/environment-variables.mdx now says what the hatch opens and what it never opens, and lists the sanctioned route for each type.

Tests and gates

Package suites, at 63ea4b2a32: origin/main 11d119ab18 merged, plus the plugin-security test change. 49f00d1b39 then changed one dogfood test file only, and shard 2/3 was re-run there.

Suite Files Tests
@objectstack/metadata-protocol 223 passed + 3 skipped 28323 passed + 19 skipped
@objectstack/rest 270 passed 5151 passed + 327 skipped
@objectstack/runtime 345 passed 5551 passed + 19 skipped
@objectstack/objectql 390 passed 7666 passed
@objectstack/plugin-security 187 passed 3915 passed + 45 skipped

Dogfood shards:

  • shard 1/3, at 63ea4b2a32: 78 files, 574 passed;
  • shard 2/3, at 49f00d1b39: 77 files, 551 passed + 1 skipped;
  • shard 3/3, at 63ea4b2a32: 76 files + 1 skipped file, 679 passed + 8 skipped.

typecheck exits 0 for plugin-security (its test layer included) and dogfood at 49f00d1b39, and for metadata-protocol, rest, runtime and objectql in the first round. No source in metadata-protocol has changed since c2d18e52f5.

Gate families come from node scripts/pm/dispatch-gates.mjs --commands, run with no paths at 49f00d1b39. All 109 commands ran, and every one exits 0. --ran reconciles: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN.

Lint. CI owns the repo-wide lint. Here, a narrowed run of eslint --no-inline-config --format json over the diff's 29 .ts files, at 49f00d1b39, gives 29 files, 0 errors and 0 warnings. The proof that narrowing excludes nothing:

  • The population comes from eslint's own config: eslint.config.mjs:971 matches **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}.
  • The file count comes from the JSON output.
  • Type-aware linting is never enabled (eslint.config.mjs:327-328), so this diff cannot move the verdict on any file it does not touch.

Serial constraints

Open questions

Q1: legacy overlay rows of types that do not merge at read are stranded. These are flow, action, hook, object and similar types. A row the hatch wrote earlier keeps serving, and can no longer be edited or removed through /meta, with the hatch set or not.

This touches ADR-0029 D9.6 ("the hatch … the one door for the life of the customization") and the ADR-0086 D1 env-overlay tighten path. Both texts now describe a door that is shut; that is a Tier H edit.

Triage answered Q1 with C: S2 stays as built, and S5 reports hatch-written environment overlay rows on sealed items at boot and stops serving them. Nothing changes for Q1 in this PR.

Q2: the /meta/types listing still reports allowOrgOverride: true, overrideSource: 'env' for a type named in the hatch. Studio's per-item lock reads the envelope, which is now correct. The listing flag is about the type, not the item. The carrier is the rename in #22340.

Q2 was answered A: the flag stays, because the per-item envelope is the truthful signal and the key belongs to #22340.

Acceptance notes

Out of scope. These are reported for the seat to file; nothing was filed here.

  • The hatch opens runtime creation of code-only types (M6), class b. Reach: on CRM, OS_METADATA_WRITABLE=job, PUT /api/v1/meta/job/s2_job_job_rest answers 200 "Saved job 's2_job_job_rest' (env-wide, state=active)". This holds at base and at head, on both transports.
  • The legacy spelling diverges between the two readers, class a. Reach: under OBJECTSTACK_METADATA_WRITABLE=job, PUT /meta/job/NEW answers 403 NOT_CREATABLE and prescribes OS_METADATA_WRITABLE, while the protocol's own reader honours the legacy spelling.

Carriers, noted, not filed. These are dead or stale after the seal:

  • dead or unreachable code:
    • the write-side OBJECT_OVERLAY_PACKAGE_MISMATCH;
    • the packaged-baseline R1 branch in plugin-security object-posture-gate;
    • DELETE_RESTRICTED at the /automation door;
    • the plugin-security lock gate's metadata-door registration;
    • the D9.7 subtraction through /meta;
  • stale comments:
    • runtime/src/domains/automation.ts:1488;
    • in plugin-security, declared to domain:services as theirs to carry: the packaged-permission-set-lock-gate.ts header, permission-set-projection.ts (around 1203, 1301, 1393), object-posture-gate.ts:14 and :93, and security-plugin.ts:4841;
  • stale docs:
    • content/docs/permissions/authorization.mdx:480-486;
    • permission-sets.mdx:363;
    • plugins/adding-a-metadata-type.mdx:59-63;
    • docs/qa/platform-checklist/areas/studio-authoring.json (it says the hatch clears the read-only badge).

Cross-lane paths

These are outside the card's declared surface, and each is a test re-premised by the seal or a new pin:

Patch round 1


Generated by Claude Code

claude added 5 commits October 9, 2026 00:27
…TABLE no longer opens an item a managed package ships (ADR-0131 D6)

WIP: the two package doors and the repository type door read one predicate
(isSealedManagedItem: artifact-backed and the registry opens no overlay
channel); the hatch keeps its type-level unlock for items no managed package
ships. Refusals name the managed package. Tests follow.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…d-content seal; re-premise the cases the hatch used to carry

The hatch-open pins flip to the seal (403 NOT_OVERRIDABLE, nothing written,
nothing removed) on the protocol, the repository, both HTTP transports and a
booted CRM; the controls (regime-O overlay, a new flow, the switch, the
linkage-free clone, the #6960 repair) stay green beside them. Cases that used
the hatch only to reach a downstream rule move onto a tenant-authored object or
a pre-seal row read by a cold boot.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…sured sentence lengths

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…er's bound; its engine double joins the pinned ledger

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 17 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/declarative-endpoints.mdx (via NOT_CREATABLE (literal, a string literal in assertAllowed))
  • content/docs/api/metadata-api.mdx (via NOT_CREATABLE (literal, a string literal in assertAllowed))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), SysMetadataRepository (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation), NOT_OVERRIDABLE (literal, a string literal in assertAllowed))
  • content/docs/deployment/environment-variables.mdx (via NOT_OVERRIDABLE (literal, a string literal in assertAllowed))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/administrator-guide.mdx (via NOT_OVERRIDABLE (literal, a string literal in assertAllowed))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/capabilities.mdx (via NOT_CREATABLE (literal, a string literal in assertAllowed))
  • content/docs/permissions/permission-sets.mdx (via NOT_OVERRIDABLE (literal, a string literal in assertAllowed))
  • content/docs/ui/public-data-collection.mdx (via NOT_OVERRIDABLE (literal, a string literal in assertAllowed))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), NOT_CREATABLE (literal, a string literal in assertAllowed))
  • content/docs/releases/v17/17-6.mdx (via NOT_OVERRIDABLE (literal, a string literal in assertAllowed))
  • content/docs/releases/v17/17-7.mdx (via NOT_OVERRIDABLE (literal, a string literal in assertAllowed))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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 — 11 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 abd254508b861da8ed1e96c2a813541b3274fb40 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json abd254508b861da8ed1e96c2a813541b3274fb40

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

claude added 2 commits October 9, 2026 03:09
…protocol package door, which now seals a managed item with the hatch open (ADR-0131 D6)

With the hatch open, a save of a package-declared permission set is refused by
the protocol's package door ahead of the authoring-gate seam, as the file's own
hatch-CLOSED case already pins, so the lock is not reached. Both cases now
assert the class is NOT the lock's, keep the NOT_OVERRIDABLE / 403 envelope and
the no-row assertion, and drop the package-id message assertion; the header
says which layer answers.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…tion rows the way an older release left them; the hatch no longer opens that save (ADR-0131 D6)

#22365's control saved stored definitions under two built-in position names
through OS_METADATA_WRITABLE=position. The positions are shipped by the
platform's own package, so the seal now refuses that save with the hatch set
too. The control pins the 403 NOT_OVERRIDABLE refusal, writes the rows at the
driver as the file's legacy-row case already does, and keeps its assertions:
the restart boots and the stored definition answers.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
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