Skip to content

fix(metadata-protocol)!: the ADR-0010 _lock gate reads the row the read serves for the request's organization (#21716) - #21737

Queued
objectstack-fleet[bot] wants to merge 7 commits into
mainfrom
claude/issue-21716-lock-org-axis-agree
Queued

objectstack-fleet[bot] wants to merge 7 commits into
mainfrom
claude/issue-21716-lock-org-axis-agree

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21716
Clause-②: no (narrowing)

What was wrong

The two item reads (getMetaItem, and getMetaItemLayered, which serves GET /api/v1/meta/:type/:name/layers) resolve a stored row by precedence: the organization's own row, else the env-wide row (ADR-0005). The ADR-0010 _lock gate's overlay limb (getEffectiveLock) asked for one row only: organization_id equal to the request's organization. So when an organization had no row of its own and the env-wide row declared _lock: 'full', the reads served the env-wide row and published lock: full, editable: false, deletable: false, while that organization's save, publish, rollback and delete were admitted. ADR-0010 §3.3 says full means "Overlay writes rejected". This is the third member of the family, on the organization axis. The first was the package door (PR #21693) and the second the topology axis (PR #21715).

H1: the two resolutions, measured at c43a8ae612

Measured on the real ObjectStackProtocolImplementation over an engine double, for a view. The table was identical on an environment kernel and a host-config kernel. "Door" is the overlay limb's own sys_metadata query.

stored rows request both reads serve door read at base door verdict
env-wide full no organization env-wide row, full organization_id null: env-wide row full: agrees
env-wide full org_a env-wide row, full organization_id = org_a: nothing none: split
org full no organization nothing, none null: nothing none: agrees
org full org_a org row, full org_a: org row full: agrees
env full + org none no organization env-wide row, full env-wide row agrees
env full + org none org_a org row, none org row agrees
env none + org full no organization env-wide row, none env-wide row agrees
env none + org full org_a org row, full org row agrees
neither either nothing, none nothing agrees

There is one split cell per kernel, and it is the card's.

What changed (H2)

Only packages/metadata-protocol/src/protocol.ts changes at runtime.

  • One resolution. A new private method, findServedOverlayRow, holds the served-row resolution. It applies the org-scoped row first and the env-wide row as the fallback, with ADR-0048 prefer-local inside each scope, and returns the row and its scope. Three callers now use it, and the three hand-written copies are gone:
    • getMetaItem's row read and its draft-preview arm;
    • getMetaItemLayered's overlay layer;
    • getEffectiveLock's overlay limb.
  • The same organization gate. The gate passes the organization through organizationIdForMetaRead, the predicate both reads apply. So on a type with no per-org channel, the door ignores an org-scoped row exactly as the reads do. Pin 4 holds this.
  • One declared difference, otherSpelling. The reads keep their at-rest tolerance: as a last resort in each scope, they also read a row stored under the type's other spelling. The gate passes false and stays on the canonical spelling. This was measured, not assumed:
  • A stale docblock fixed. The getEffectiveLock header still told callers to gate on environmentId, which has not been true since finding(metadata-protocol): two lock reports the #21670 read fix left unaligned — a host-config kernel's _lock gate admits a save the read now calls non-editable, and getMetaDiagnostics().stats[type].locked counts declared _lock only #21694. It now says the method answers alike on every topology.
  • The ledger. scripts/engine-double-contract.pinned.json gains one row for the new pin's findOne double. It was written by the gate's own --write: 1 added, 0 lost.

No packages/spec/src/** file is touched, so no contract review is owed on that ground. No governed surface is touched.

H3: precedence when both rows exist

The reads serve the organization's own row to that organization. That precedence is the ruling recorded in getMetaItem (ADR-0005: precedence, never a merge). The gate now binds the lock of that same row, whatever the env-wide row declares, and pin 3 covers it.

ADR-0010 §3.3 states its lock table per item and records no cross-scope cascade. I found no text under which the reads' precedence is wrong, so the precedence is unchanged here.

H4: every caller moves together

getEffectiveLock has two callers. Both read the new limb, because the change is inside it:

  • lockWriteRefusal is reached through:
    • assertLockAllowsWrite, from saveMetaItem and rollbackMetaItem;
    • promoteDraftForPublish, from publishMetaItem and publishPackageDrafts.
  • assertLockAllowsDelete is reached from deleteMetaItem, which deletePackage and discardPackageDrafts also call.

Pin 2 drives save, delete, publish and rollback.

What moves (H5)

  • Tests: none re-aimed. No existing test changed in the final shape.
    • The one red seen during the work was the canonicalization case above. It was caused by a first shape of this fix, not by the split, and it is green again under the final shape.
    • metadata-protocol: 212 files and 3675 tests pass (3589 before, plus 86 new).
    • objectql: 372 files and 7458 tests pass.
  • Examples. git grep finds no _lock and no protection: under examples/**.
  • Shipped locks. platform-objects declares protection.lock only on app items (setup, studio, account) and object items (sys_*). Both types are allowOrgOverride: false, so the gate never carries an organization for them, and their artifact limb is unchanged.
  • Writers that reach the gate with an organization:
    • over the wire, REST PUT, DELETE, publish and rollback, and the runtime dispatcher's save. They carry organizationIdForMetaWrite, which is non-empty only for view, dashboard, report, translation and email_template;
    • in process, migrateStoredMetadata (the row's own organization, so its own row is served and nothing changes), deletePackage and discardPackageDrafts (the row's organization), and duplicatePackage (a new name).
  • Newly refused. An org-scoped write of one of those five types is now refused ITEM_LOCKED / 403 when that organization has no row of its own and the env-wide row's lock refuses the operation. On a type with no per-org channel, an in-process removal of a pre-org 作用域的 flow overlay 只在「本进程内发布后」绑定触发器,重启后静默失绑——冷启动两条读路径都把 organization_id 非空的行滤掉了 #6190 org-scoped row is now judged by the env-wide row's lock, the row both reads serve.
  • The dev server's boot and seed replay. Seed rows are type seed, which has no per-org channel, and they name no organization. The boot path reads; it does not write through these doors. This was measured by git grep of every non-test caller of the four write verbs. NOT MEASURED: a live server boot (no dev server was started for this card).
  • Cost. With an organization, the gate makes 2 findOne reads on a miss (the org row, then the env-wide row) where it made 1. Without an organization nothing changes.

Pins

packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts has 86 cases. Each one drives the real doors and the real reads on one protocol instance, and every refusal asserts code and status (ADR-0112).

  1. The family's enumeration pin (triage's acceptance). One table covers kernel topology (environment, host-config), row scope (env-wide, org-scoped), request scope (no organization, org_a), every MetadataLockSchema level (read off the schema itself) and operation (save, delete). That is 64 rows, each its own named case.
    • In every row the door admits exactly when the envelope says editable (save) or deletable (delete).
    • Both reads must agree with each other first.
    • A span check holds the table size to the product of the axes, and a lit control proves the org axis reaches the env-wide row.
  2. The measured defect. An env-wide _lock: 'full' row is tested on both kernels.
    • The org-scoped read says editable: false.
    • Save and delete are refused ITEM_LOCKED / 403 with lock: 'full'. The denial rows are written to sys_metadata_audit under org_a.
    • Publish and rollback are refused the same way.
  3. Precedence (H3). Three lock pairs ((full, none), (none, full), (no-delete, no-overlay)) are tested against both request scopes on both kernels. The served row is named in every case, and both doors follow it.
  4. The organization gate. A page (no per-org channel) is tested with an env-wide row and an org-scoped residue row whose locks differ. The read serves the env-wide row, and the door binds that row's lock.

Reverse verification

Both ablations ran from the committed fix (HEAD 7b37480d8c), through scripts/ablation-replace.mjs in wrap mode, inside a script whose EXIT INT TERM trap restores from HEAD by absolute path. The pin imports ./protocol.js, which resolves to source, so no rebuild is in the path. The expected direction was declared before each run.

  1. The exact-organization_id limb restored (one findOne on organization_id: organizationId ?? null).
    • The anchor went from 1 to 0, and 1 marker was on disk. The blob went from e6a207612cc4 to c598f7de3b47.
    • Predicted: 16 red, 70 green. Measured: 16 failed, 70 passed.
    • Red: the 8 org-axis cells of pin 1 (an env-wide row, an org_a request, with no-overlay/full on save and no-delete/full on delete, on both kernels), all 4 cases of pin 2, and all 4 of pin 4.
    • Green: the other 56 rows of pin 1, the span check and the lit control, and all of pin 3.
  2. The organization gate removed from the door only.
    • The anchor went from 1 to 0, and 1 marker was on disk.
    • Predicted: 4 red. Measured: 4 failed, 82 passed, all of them pin 4.

Restore. After each leg, the blob equals the HEAD blob e6a207612cc4, git diff HEAD is empty, and git status --porcelain is empty. Both the tool and the trap proved it.

Tests

On head 87e350bbaf, after merging origin/main at 316be321ef (which touched runtime and scripts/ only):

  • @objectstack/metadata-protocol:
    • vitest run: 212 files passed and 3 skipped; 3675 tests passed and 19 skipped.
    • typecheck (tsc --noEmit) is green. --listFiles compiles 215 test files, including the new pin.
  • @objectstack/objectql, against the rebuilt metadata-protocol dist:
    • vitest run --project local: 372 files and 7458 tests passed.
    • --project repo: 1 file and 5 tests passed.
  • Lint, narrowed and proved. eslint --no-inline-config --format json over the two changed TypeScript files gives 2 file results, with 0 errors and 0 warnings.
    • The population comes from ESLint's own config: isPathIgnored is false for both files, and each computes a 5-rule config.
    • Invariance: neither computed config has parserOptions.project or projectService, and eslint.config.mjs states it never enables type-aware linting. So this diff cannot move any untouched file's verdict.

NOT MEASURED, owned by CI or out of reach here:

  • HTTP: the reach is on the real protocol over doubles.
  • a live server boot.
  • the rest and runtime suites. They are consumers, and no export, spec contract or wire shape changes.
  • the Dogfood Regression Gate.
  • Temporal Conformance.
  • the whole-workspace type-check lanes.
  • the full pnpm lint.

Gates

All of these ran on head 87e350bbaf, after the final commit. Each exit code was captured before any pipe.

  • Derived set. node scripts/pm/dispatch-gates.mjs --commands (no paths) derives 72 families. All 72 exit 0. The --ran reconciliation reads "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN".
    • Among them: check:adr-0087-registration (1 breaking changeset, carrying its disposition), check:changeset-no-major, check:empty-changeset, check:engine-double-contract (855 rows held), check:nul-bytes, check:doc-authoring, check:cross-package-test-inputs, check:test-source-alias, check:published-files, check:dts-closure, check:dual-build-cjs-loads and check:lean-entry-closure.
    • The last two first answered PREREQUISITE NOT MET (exit 3). They were re-run after a full turbo run build and exit 0.
  • Artifact-roster block. All 54 roster commands were run; 51 exit 0.
    • check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths exit 2 NOT WIRED, because they read a pull request.
  • The four symbol-anchor sweeps. All four exit 0:
    • check:adr-symbol-anchors: 2167 anchors across 140 records resolve.
    • check:scripts-symbol-anchors: 3760 anchors across 282 scripts resolve.
    • check:spec-docblock-symbol-anchors: 4950 anchors across 1868 spec sources resolve.
    • check:adr-anchors: OK.

Acceptance notes

Three members of the same family sit outside triage's five axes. Each was measured on the real reads and the gate over doubles, at c43a8ae612, and none is fixed here. They are reported to the seat for its call.

  • The artifact axis: an explicit none. A packaged view declares _lock: 'none', and its stored row declares full.
    • Both reads say editable: true, because mergeArtifactProtection lets the artifact's explicit value win.
    • The gate refuses, because its artifact limb skips none and its overlay limb finds full.
    • The door is stricter than the read.
  • The layered read: a packaged item's stored lock. A packaged view declares no lock, and its stored row declares full.
    • getMetaItem says full / editable: false, which agrees with the door.
    • getMetaItemLayered says none / editable: true, because its lock source is code ?? overlay.
    • The two reads disagree with each other.
  • The other spelling. This is the declared difference above. A pre-meta overlays: unnormalized type segment creates phantom rows that shadow the code-authored listing and cannot be deleted #4432 row stored under the plural spelling is served by the reads when no canonical row exists in that scope. The gate does not read it. No live write mints such a row.

Two smaller notes:

  • The package axis. The gate asks without a packageId, while a read that names one prefers that package's row (ADR-0048). The two can split only when one (type, name, scope) holds rows from two packages. Not measured.
  • One pathological layered case. getMetaItemLayered used to fall back to the env-wide row when an org row's stored body was JSON null. It now reports the org row, as getMetaItem always did.

Generated by Claude Code

claude added 6 commits October 4, 2026 11:32
…the read serves for the organization

getEffectiveLock's overlay limb asked for organization_id equal to the
request's organization only, while getMetaItem and getMetaItemLayered resolve
the org-scoped row, else the env-wide row (ADR-0005). An env-wide row
declaring _lock: full read locked for an organization with no row of its own,
and that organization's save, publish, rollback and delete were admitted.

One resolution, findServedOverlayRow, now serves both reads (the draft
preview arm included) and the gate's overlay limb, behind the reads' own
organizationIdForMetaRead gate. The family's enumeration pin drives the read
and the door over topology x row scope x request scope x every lock level x
operation on one protocol instance per row.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
…g inside the shared served-row resolution

The reads' at-rest tolerance for a row stored under the type's other
spelling is not extended into the write path: a write addresses the
canonical namespace only. It is the one declared difference, a parameter
of findServedOverlayRow rather than a second query.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
…res with the reads

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
…e-double ledger

Written by check-engine-double-contract --write: 1 added, 0 lost.

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

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/api/index.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/automation/flows.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), getMetaItemLayered (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/data-modeling/drivers.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/data-modeling/objects.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/deployment/cli.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/deployment/environment-variables.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/deployment/validating-metadata.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/kernel/cluster.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/kernel/contracts/metadata-service.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow), getPublished (sdk, the bare tail of client method meta.getPublished, bound to GET /api/v1/meta/:type/:name/published; the bare tail of client method meta.getPublished, bound to GET /meta/:type/:name/published))
  • content/docs/kernel/services-checklist.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/permissions/authorization.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/permissions/permission-sets.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/plugins/packages.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))

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

  • content/docs/releases/implementation-status.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/releases/v17/17-3.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow), /:type/:name/published (route, bridged from symbol getMetaItemLayered — its route source's handler names it))
  • content/docs/releases/v17/17-5.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))
  • content/docs/releases/v17/17-6.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow))

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 316be321ef2405a12e8d016f07b25fad7039b4f9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 316be321ef2405a12e8d016f07b25fad7039b4f9

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

REWORK (narrow, one changeset sentence) — PR #21737 at head 87e350bbaf

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-04T12:44Z. The os-dev report is on #21716. Judged against the branch, not against the report. Everything below the first section is accepted as it stands.

The one thing to change

  • The changeset says "Unchanged. … Both reads." That is false for one read.
    • At base, getMetaItemLayered read the org row. When convertStoredItem returned null for it (a stored body of JSON null), it fell back to the env-wide row (if (overlay === null)).
    • Through findServedOverlayRow it now serves the org row (overlayScope: 'org', overlay null), which is what getMetaItem always did. The dev's own note (3) records this.
  • The seat keeps the behaviour. The two reads now agree, which is this family's rule. The sentence must state it.
    • Say that getMetaItemLayered now serves an organization's own stored row when that row's body is JSON null, as getMetaItem already did, instead of falling back to the env-wide row.
    • Say who can see it: residue only, since no live writer stores a null body; measure that or say so.
  • No code change. Commit the changeset edit, push once with no force, then post a short addendum on [finding] an env-wide metadata row declaring _lock: full reads locked in an org-scoped read, but an org-scoped save of it is admitted — getEffectiveLock's overlay limb matches organization_id exactly #21716.

The open question — answered: A

The gate stays canonical-only (otherSpelling: false), and the difference is declared on findServedOverlayRow.

Accepted as it stands

  • One resolution: findServedOverlayRow serves the org row, else the env-wide row (ADR-0005), with ADR-0048 prefer-local, behind organizationIdForMetaRead. getMetaItem (the row read and the draft-preview arm), getMetaItemLayered and getEffectiveLock's overlay limb all call it.
  • The census (9 cells, before and after, both kernels): the single split cell was the card. After the fix there are 0 split cells.
  • The enumeration pin, triage's acceptance: 64 named rows over topology × row scope × request scope × every MetadataLockSchema level × operation, one protocol per row, with both sides on one kernel. Plus the defect, precedence and org-gate pins: 86 cases.
  • Reverse verification: ablation 1 restored the exact-organization_id limb and turned 16 cases red, as predicted. Ablation 2 removed the org gate from the door only and turned the 4 pin-4 cases red. Restores were proved by blob equality and empty diff and status.
  • Evidence: metadata-protocol passes 3675 tests and objectql 7458 against the rebuilt dist; typecheck is green.
  • Gates: 72 of 72 derived exit 0, the 54-family roster is green including the PR-context guards after pr_create, and the four symbol-anchor sweeps exit 0. The engine-double row was written by the gate's own --write.
  • Clause-②: no (narrowing): an org-scoped write that an env-wide lock declares refused is now refused. Nothing widens, and there is no packages/spec path, so no contract review is owed.
  • The other changeset sentences, checked: minor, BREAKING, the adr-0087 marker, "What is refused now" (including the five per-org overridable types over the wire, and the in-process deletePackage / discardPackageDrafts case), the remedy, and "Unchanged" apart from the one sentence above.

The family's remaining positions — filed by the seat as ONE card (同族只开一张)

This card closes the axes triage enumerated. The dev measured two more read-versus-door splits on the artifact-layer axis, both class b with reach on the real reads and gate over an engine double:

  1. An artifact that declares an explicit _lock: 'none' over an env-wide row declaring 'full': the reads say editable: true (mergeArtifactProtection copies 'none'), while the door refuses (the artifact limb skips 'none').
  2. getMetaItemLayered's lock source is code ?? overlay, so for a packaged item whose stored row declares full, the layered read says none while getMetaItem and the door say locked.

Together with C (the spelling residue), they go on one family card that extends the enumeration pin with the artifact-layer axis. The package axis (the gate asks without packageId) stays an Acceptance note: it is not measured.


Generated by Claude Code

…row resolution makes

The layered read now serves an organization's own stored row whose body is
JSON null, as getMetaItem already did, instead of falling back to the
env-wide row. No live writer stores a null body.

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

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #21737 at head f508c21ce3

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-04T12:50Z. This closes the narrow REWORK 5980059935. The dev's addendum is 5980100867 on #21716.

  • The delta, read: one commit on 87e350bbaf, .changeset/21716-lock-org-axis-agree.md only, 1 line. No code change.
  • The sentence, checked: "Both reads." becomes "Both reads, apart from one case in getMetaItemLayered: it now serves an organization's own stored row whose body is JSON null, as getMetaItem already did, instead of falling back to the env-wide row …". That matches the read hunk: findServedOverlayRow returns the org row, and its null body is no longer a reason to look further.
  • Reach, measured: no live writer stores a null body. SysMetadataRepository.put writes JSON.stringify(spec ?? {}). saveMetaItem refuses a missing or null item with 400 INVALID_REQUEST at its top. The only other sys_metadata writer (service-datasource's persistDatasourceRow) stores an object env-wide. Residue only, as stated.
  • Gates at this head:
    • check-adr-0087-registration --base origin/main and check-changeset-no-major --base origin/main exit 0, as does every derived family that reads .changeset/**.
    • The 3 PR-context guards exit 0 with this PR's context.
    • The code and the test readings from 87e350bbaf stand.
  • Everything else is accepted as listed in 5980059935:
  • CI: read by the seat at landing on this head.

Generated by Claude Code

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/l tests tooling

Projects

None yet

2 participants