fix(metadata-protocol)!: the ADR-0010 _lock gate reads the row the read serves for the request's organization (#21716) - #21737
objectstack-fleet[bot] wants to merge 7 commits into
Conversation
…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
…ck-org-axis-agree
…e organization 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
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
REWORK (narrow, one changeset sentence) — PR #21737 at head
|
…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
ACCEPT — PR #21737 at head
|
Fixes #21716
Clause-②: no (narrowing)
What was wrong
The two item reads (
getMetaItem, andgetMetaItemLayered, which servesGET /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_lockgate's overlay limb (getEffectiveLock) asked for one row only:organization_idequal 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 publishedlock: full,editable: false,deletable: false, while that organization's save, publish, rollback and delete were admitted. ADR-0010 §3.3 saysfullmeans "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
c43a8ae612Measured on the real
ObjectStackProtocolImplementationover an engine double, for aview. The table was identical on an environment kernel and a host-config kernel. "Door" is the overlay limb's ownsys_metadataquery.fullfullorganization_idnull: env-wide rowfull: agreesfullorg_afullorganization_id = org_a: nothingnone: splitfullnonenone: agreesfullorg_afullfull: agreesfull+ orgnonefullfull+ orgnoneorg_anonenone+ orgfullnonenone+ orgfullorg_afullnoneThere is one split cell per kernel, and it is the card's.
What changed (H2)
Only
packages/metadata-protocol/src/protocol.tschanges at runtime.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.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.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 passesfalseand stays on the canonical spelling. This was measured, not assumed:packages/objectql/src/protocol-meta-type-canonicalization.test.tsred ("a plural-spelled write addresses the canonical namespace and no other"). On a create, the gate queriedtype: 'actions'after the canonical row missed.findServedOverlayRowand in the gate's header.getEffectiveLockheader still told callers to gate onenvironmentId, which has not been true since finding(metadata-protocol): two lock reports the #21670 read fix left unaligned — a host-config kernel's_lockgate admits a save the read now calls non-editable, andgetMetaDiagnostics().stats[type].lockedcounts declared_lockonly #21694. It now says the method answers alike on every topology.scripts/engine-double-contract.pinned.jsongains one row for the new pin'sfindOnedouble. 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
getEffectiveLockhas two callers. Both read the new limb, because the change is inside it:lockWriteRefusalis reached through:assertLockAllowsWrite, fromsaveMetaItemandrollbackMetaItem;promoteDraftForPublish, frompublishMetaItemandpublishPackageDrafts.assertLockAllowsDeleteis reached fromdeleteMetaItem, whichdeletePackageanddiscardPackageDraftsalso call.Pin 2 drives save, delete, publish and rollback.
What moves (H5)
metadata-protocol: 212 files and 3675 tests pass (3589 before, plus 86 new).objectql: 372 files and 7458 tests pass.git grepfinds no_lockand noprotection:underexamples/**.platform-objectsdeclaresprotection.lockonly onappitems (setup,studio,account) andobjectitems (sys_*). Both types areallowOrgOverride: false, so the gate never carries an organization for them, and their artifact limb is unchanged.PUT,DELETE, publish and rollback, and the runtime dispatcher's save. They carryorganizationIdForMetaWrite, which is non-empty only forview,dashboard,report,translationandemail_template;migrateStoredMetadata(the row's own organization, so its own row is served and nothing changes),deletePackageanddiscardPackageDrafts(the row's organization), andduplicatePackage(a new name).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.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 bygit grepof every non-test caller of the four write verbs. NOT MEASURED: a live server boot (no dev server was started for this card).findOnereads 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.tshas 86 cases. Each one drives the real doors and the real reads on one protocol instance, and every refusal assertscodeandstatus(ADR-0112).org_a), everyMetadataLockSchemalevel (read off the schema itself) and operation (save, delete). That is 64 rows, each its own named case.editable(save) ordeletable(delete)._lock: 'full'row is tested on both kernels.editable: false.ITEM_LOCKED/ 403 withlock: 'full'. The denial rows are written tosys_metadata_auditunderorg_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), throughscripts/ablation-replace.mjsin wrap mode, inside a script whoseEXIT INT TERMtrap restores fromHEADby 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.organization_idlimb restored (onefindOneonorganization_id: organizationId ?? null).e6a207612cc4toc598f7de3b47.org_arequest, withno-overlay/fullon save andno-delete/fullon delete, on both kernels), all 4 cases of pin 2, and all 4 of pin 4.Restore. After each leg, the blob equals the
HEADblobe6a207612cc4,git diff HEADis empty, andgit status --porcelainis empty. Both the tool and the trap proved it.Tests
On head
87e350bbaf, after mergingorigin/mainat316be321ef(which touchedruntimeandscripts/only):@objectstack/metadata-protocol:vitest run: 212 files passed and 3 skipped; 3675 tests passed and 19 skipped.typecheck(tsc --noEmit) is green.--listFilescompiles 215 test files, including the new pin.@objectstack/objectql, against the rebuiltmetadata-protocoldist:vitest run --project local: 372 files and 7458 tests passed.--project repo: 1 file and 5 tests passed.eslint --no-inline-config --format jsonover the two changed TypeScript files gives 2 file results, with 0 errors and 0 warnings.isPathIgnoredis false for both files, and each computes a 5-rule config.parserOptions.projectorprojectService, andeslint.config.mjsstates 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:
restandruntimesuites. They are consumers, and no export, spec contract or wire shape changes.pnpm lint.Gates
All of these ran on head
87e350bbaf, after the final commit. Each exit code was captured before any pipe.node scripts/pm/dispatch-gates.mjs --commands(no paths) derives 72 families. All 72 exit 0. The--ranreconciliation reads "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN".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-loadsandcheck:lean-entry-closure.turbo run buildand exit 0.check-closing-target-claim,check-partof-closing-keywordandcheck-single-claim-pathsexit 2 NOT WIRED, because they read a pull request.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.none. A packaged view declares_lock: 'none', and its stored row declaresfull.editable: true, becausemergeArtifactProtectionlets the artifact's explicit value win.noneand its overlay limb findsfull.full.getMetaItemsaysfull/editable: false, which agrees with the door.getMetaItemLayeredsaysnone/editable: true, because its lock source iscode ?? overlay.Two smaller notes:
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.getMetaItemLayeredused to fall back to the env-wide row when an org row's stored body was JSONnull. It now reports the org row, asgetMetaItemalways did.Generated by Claude Code