Skip to content

fix(metadata-protocol)!: an organization's seed load resolves people through its membership, reports a refused reference probe, and writes as that organization - #33222

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-e226dr
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-e226dr

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#7 · os-tesla · session_01L9qibzSAkofrHDU1U8yFeA

Requested by Tesla · project thread

Fixes #33207
Fixes #33206

Clause-②: yes (narrowing) — narrowed: under a walled tenancy posture (isolated or group), every read and by-id write a seed write performs or triggers (a hook's ctx.api read, the engine's own reads on that row, the loader's update of an existing row and its pass-2 back-fill) is scoped to the organization the row is written as, where it reached every organization's rows. Widened: on a load for one organization, a person reference resolves to a member of that organization, sys_organization to the load's own, an object no organization walls (no organization_id column and no tenant field) across organizations, and a hook on a seeded row sees the organization the row is written as. Nothing that resolved before stops resolving; no load that succeeded is refused.
Metadata-protocol: none

Contract review at CONTRACT_REVIEW_TIER (narrowing): round 1 FAIL on 3c36f9c6e (one must-fix, two should-fix, three nits). The fixes are on this head; the same reviewer owes the delta review before the seat lands it.

维护者速读

  • 改了什么:按组织回放种子数据时,人员引用改为只在该组织成员中解析(非成员绝不借用);被数据库拒绝的引用查询如实报出;种子写入带上行所属组织,hook 能看到 organizationId。
  • 为什么改:下游应用在 18.4.0 上发现,演示数据里的负责人全部落空、依赖组织的 system hook 什么也算不出,样例数据无法作为标准种子交付。
  • 风险与回滚:BREAKING——walled 模式下种子写入触发的 hook 和引擎读取只能看到该行所属组织的数据(此前能看到所有组织);只改 seed-loader.ts,回滚即还原该文件。
  • 先看:packages/metadata-protocol/src/seed-loader.ts 的 resolveFromDatabase / probeScopeOf / probeOrganizationMember,以及 loadDatasets 里 seedOptions 的赋值。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/seed-org-scoped-references.integration.test.ts,预期 17 个用例全绿。
  • 需要你做的:无,增量合约复审通过后由席位落地。

What changes

#33207: references on an organization's own load. resolveFromDatabase AND-scoped every probe with organization_id, sys_user included. sys_user has no such column, so the SQL driver refused the probe (INVALID_FILTER), the bare catch read it as "no match", and the reference was reported as "no such sys_user row exists". Measured on main 6e1ab9b with the real AppPlugin replayer, ObjectQL and SqlDriver: the single-tenant boot resolved rep: 'Zhang Wei' to usr_zhang; both per-organization replays left it null, the member's organization included.

Each target is now judged once per load (probeScopeOf) from the definition the stamp already reads:

  1. carries an organization_id column (declared or injected), or cannot be described: filtered to the load's organization, as before;
  2. sys_user: candidates by the field, read in id order page by page (50 a page) until one holds a sys_member row in the load's organization (probeOrganizationMember). A same-named person outside it is never used, and "not a member" is said only after every candidate was asked;
  3. sys_organization: the load's own organization only;
  4. no organization column, author-owned (no managedBy) and resolving no tenant field at all (resolveTenantFieldName, the reading the wall scopes by): global, unscoped. An object walled by another tenant column (tenancy.tenantField) and platform-managed tables without the column keep the old probe, and its refusal is now reported.

A load with no organization probes unscoped, exactly as before.

The narrowed catch. A refused probe records its code and the chain moves on. The load is never refused for it. One warn per target, field and code per load; the pass-2 error keeps its first sentence and appends the refusal's code (or, for a person outside the organization, that the person is not a member) instead of claiming no row exists.

#33206: the organization in the write context. ExecutionContext has no organizationId key: a hook's session.organizationId and its ctx.api scope are built from tenantId (measured: the card's { ...SEED_WRITE_EXECUTION_CONTEXT, organizationId } reaches no hook). The loader now builds its options bag per load: { context: { ...SEED_WRITE_EXECUTION_CONTEXT, tenantId } }, with tenantId the organization the load stamps its rows with (config.organizationId ?? fallbackOrgId, the seat's ruling on the shape note). A row that supplies its own, different organization_id is written as that organization (writeOptionsFor): its insert or update, and its pass-2 back-fill, so a walled by-id write finds the row; a batch holds rows of one write organization. SEED_WRITE_EXECUTION_CONTEXT is unchanged. A load with no organization, pinned or derived, uses the frozen SEED_OPTIONS bag itself. The landed-key ledger insert uses the per-load bag.

A pass-2 back-fill whose update answers null (the data-engine contract's "matched no row") is reported as a failed back-fill, never counted as resolved. ObjectQL itself already refuses a by-id update it cannot find with a not-found error, which the back-fill reports.

skipTriggers does not stop hooks: it reaches only the record-change trigger's flow dispatch, and triggerHooks skips metadata hooks only on skipAutomations. So the card's premise stands: app hooks run on seed writes.

Tests

New packages/runtime/src/seed-org-scoped-references.integration.test.ts (real AppPlugin, its walled seed-replayer and its single inline seed, SeedLoaderService, ObjectQL, SqlDriver over better-sqlite3), 17 cases:

  • a member of the load organization is resolved (rep and owner_id);
  • a same-named person who is not a member is NOT resolved (two people named Wang Fang, one per organization, the other organization's created first; Zhang Wei, a member of North only, is not given to South);
  • a member among sixty same-named people (more than one page) is found, and "not a member" is said only after all were asked;
  • a refused probe is reported with its code, once per target, field and code;
  • a global catalog resolves; an object walled by tenantField: 'workspace_id' is not taken for global (another organization's row is not borrowed); the organization table answers only for the load organization;
  • CONTROL: a tenant-carrying target is still filtered (South's Globex is not borrowed by North);
  • CONTROL: a load with no organization probes unscoped, with no membership asked;
  • a hook sees the load organization on a replay, a reseed, the single boot (sole organization) and a direct pinned load (the install path);
  • a row that supplies its own organization is written as it, its pass-2 back-fill included (walled posture);
  • a pass-2 back-fill the write organization cannot see is reported, not counted as resolved;
  • CONTROL: a load with no organization writes with the seed posture unchanged (session equals { isSystem, skipTriggers });
  • the declared narrowing: under isolated and under group, a hook's ctx.api read on a seeded row sees only the load organization.

And one case in packages/metadata-protocol/src/seed-loader-deferred-failure.test.ts: an engine update answering null on the back-fill is reported at error with "matched no row" and is not counted as resolved.

Ablations (every negative pin, plus the two positive halves): mutate, rebuild, marker proved in dist/, run, restore

Each leg through scripts/ablation-replace.mjs (anchor must hit, restore proved by blob hash), OS_SKIP_DTS=1 pnpm --filter @objectstack/metadata-protocol build, scripts/ablation-dist-preflight.mjs (marker present in 2 built files), then the file above.

leg mutation red
A first candidate taken, membership ignored non-member NOT resolved
B organization filter dropped for tenant targets CONTROL tenant target; refused probe
C refusal swallowed (old bare catch) refused probe reported
D own-organization filter dropped organization table answers only for the load organization
E write context without the organization replay/reseed hook, single hook, install hook, declared narrowing
F sys_user probed with the organization filter (main) member resolved; non-member
G (r1) one page of candidates only member among sixty same-named people
H (r1) global without the tenant-field reading workspace_id object not borrowed
I (r1) every row written as the load organization own-organization row and its back-fill
J (r1) back-fill written as the load organization own-organization row and its back-fill
E (r1, again) write context without the organization the four hook pins, narrowing under isolated and group
K (r1) null answer not checked the unit pin: matched no row

Every leg: ok restored: blob == HEAD and git diff HEAD empty (r0 legs at blob 337629ad067a, r1 legs at de0e52ac36a2 / c3371fee5f33). Restore leg r1: rebuilt, markers G H I J E --absent from dist/, file 17/17 green, tree clean.

Suites and gates
  • pnpm --filter @objectstack/metadata-protocol exec vitest run: r1 Test Files 339 passed, 9 skipped; Tests 22844 passed, 43 skipped. No existing seed-loader pin needed a change.
  • pnpm --filter @objectstack/metadata-protocol typecheck, pnpm --filter @objectstack/runtime typecheck (with check:test-typecheck): exit 0, after a declaration build of the runtime closure.
  • pnpm lint: exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands: 94 lines, each run with stdin closed. 90 exit 0 on first run; check:query-options-erasure (two new untyped probe bags, now typed EngineQueryOptions) and check:test-source-alias (the pins reach AppPlugin's lazy @objectstack/metadata import; now warmed at module top) fixed and exit 0; check:skill-examples and check-plugin-teardown-shape --self-test refused for a missing prerequisite (spec declarations, a commit outside the shallow clone), both exit 0 once met. check:dual-build-cjs-loads: NOT MEASURED, reason: it needs every package's dist/ (a full pnpm build); CI's Lint & Repo Gates runs it.
  • Full dogfood on head 3c36f9c6e (after merging origin/main once), command as CI: build the closure, then pnpm --filter @objectstack/dogfood test under the lock (no shard, no path): VERDICT command-exit 1, Test Files 1 failed | 463 passed | 3 skipped (467), Tests 6 failed | 3921 passed | 42 skipped (3969). The one red file, test/rest-route-generation-tombstones.test.ts, failed every case on TS7016: Could not find a declaration file for module '@objectstack/spec/api': my post-merge rebuild used OS_SKIP_DTS=1, so packages/spec/dist held no declarations for its tsc fixture. After pnpm --filter @objectstack/spec build with declarations, that file: Test Files 1 passed, Tests 10 passed. Nothing in the diff reaches it.
  • After the merge: the four seed files in packages/runtime (this file, seed-replay-per-organization-identity, seed-loader-null-organization-holder-real-driver, app-plugin.seed-landed): Test Files 4 passed, Tests 29 passed.

Acceptance notes

  • AppPlugin's no-metadata fallback (a basic insert per record when the metadata service is absent) writes with SEED_WRITE_OPTIONS, stamps nothing and has no organization; untouched.
  • A person resolves through membership only; sys_member declares no validity columns today, so "member" is "holds a row in the organization".
  • Platform-managed tables with no organization column (identity, auth and engine tables other than sys_user and sys_organization) keep the filtered probe on an organization's load; it is refused and now said once. No scope rule is invented for them.
  • The single boot, a publish and a hot reload probe references unscoped, as before; only their write context gains the sole organization.

Verification

Round 1 (review fixes): two commits on top of 3c36f9c6e, pushed head 752448bfb. Round 0 head: 3c36f9c6e (merge of origin/main at 6e1ab9b2..its tip, three commits, none on the seed loader or the replayer; no open PR touches seed-loader.ts). Measured before the change on main 6e1ab9b; shape note server-seat-7/r2026-11-13/out/33207-33206-proposed-shape.md, accepted by the seat (claim amendments 6871524022 on #33207 and 6871533047 on #33206).

🤖 Generated with Claude Code

https://claude.ai/code/session_01L9qibzSAkofrHDU1U8yFeA


Generated by Claude Code

…membership and carry the organization to hooks

WIP: probe scope per target, refused probes reported, per-load write context.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzSAkofrHDU1U8yFeA
…azy metadata import in the pins

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzSAkofrHDU1U8yFeA
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/data-modeling/drivers.mdx (via user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/data-modeling/validation.mdx (via SeedLoaderService (symbol, a top-level class))
  • content/docs/deployment/cli.mdx (via user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/kernel/runtime-services/audit-service.mdx (via user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/permissions/authentication.mdx (via user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/permissions/delegated-administration.mdx (via user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/permissions/permission-sets.mdx (via user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/permissions/positions.mdx (via user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/permissions/record-view-auditing.mdx (via user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/permissions/system-context.mdx (via writeDeferredReference (symbol, a method of class SeedLoaderService), user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/protocol/kernel/config-resolution.mdx (via user_id (literal, a string literal in probeOrganizationMember))
  • content/docs/protocol/objectql/state-machine.mdx (via SeedLoaderService (symbol, a top-level class))

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

  • content/docs/releases/v17/17-1.mdx (via SeedLoaderService (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via user_id (literal, a string literal in probeOrganizationMember))

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
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 40 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 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 — 10 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 e540baf8873c22642ce191b99e7e5ce1fd3b4620 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 e540baf8873c22642ce191b99e7e5ce1fd3b4620 → 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

domain:server#7 · os-tesla · session_01L9qibzSAkofrHDU1U8yFeA

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3c36f9c6edfb7f70ef9143b0e65bc1fb036d49d8
Local-runs: none
Scope: packages/metadata-protocol/src/seed-loader.ts, packages/runtime/src/seed-org-scoped-references.integration.test.ts + read in the cited code, never reviewed: packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/data/injected-system-columns.ts, packages/spec/src/data/object.zod.ts, packages/spec/src/system/constants/system-names.ts, packages/core/src/security/tenant-layer0-verdict.ts, packages/core/src/security/tenant-field.ts, packages/objectql/src/engine-driver-options.ts, packages/objectql/src/engine-execution-plumbing.ts, packages/objectql/src/engine-insert.ts, packages/objectql/src/engine-update.ts, packages/objectql/src/engine-middleware-and-context.ts, packages/objectql/src/engine-cascade-planning.ts, packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts, packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts, packages/drivers/driver-sql/src/sql-driver-rotation.ts, packages/platform-objects/src/identity/sys-user.object.ts, packages/platform-objects/src/identity/sys-member.object.ts, packages/runtime/src/app-plugin.ts, packages/runtime/src/domains/packages.ts, packages/metadata-protocol/src/protocol-publish-batch.ts

Inputs read: cards #33207 and #33206 (bodies and all five comments each, the claim amendments 6871524022 and 6871533047 included), PR #33222 (body, four files, net diff against main), and the 53 check-runs on this head: every run that executed concluded success (Build Core, Test Core 1/8 to 8/8, TypeScript Type Check, Lint and Repo Gates, Check Changeset, Governed Surface Queue Guard, PR-body guards, Build Docs, Spec property liveness); the rest are path-filtered skips. No earlier contract-review record exists on either card or the PR.

① Derived judgments

  • Public surface: no exported symbol, schema key or config field is added, removed or re-shaped. SEED_WRITE_EXECUTION_CONTEXT is untouched (packages/spec/src/kernel/execution-context.zod.ts:735); the new members are module-private (carriesOrganizationColumn, ProbeRefusal, DatabaseProbeOutcome) or private class members. Metadata-protocol: none is RIGHT.
  • Accept-set of the reference probe on a load pinned to an organization (seed-loader.ts:2797, 2898-2915): a sys_user target moves from never-resolves to resolves-through-sys_member; sys_organization from never to own-organization only; an author object with no organization_id column from never to unscoped; every other target is unchanged. The direction (widening) is RIGHT for the first two. The third is WRONG at its edge: see ④ finding 2.
  • The key choice tenantId over the card's organizationId is RIGHT: a hook's session.organizationId is built from execCtx.tenantId (packages/objectql/src/engine-execution-plumbing.ts:68) and ExecutionContext declares no organizationId key (execution-context.zod.ts:157). { isSystem: true, tenantId } is also the shape the engine itself asks a system write to reach (packages/objectql/src/engine-middleware-and-context.ts:337).
  • The organization on the context is config.organizationId ?? this.fallbackOrgId (seed-loader.ts:1110), the same expression the stamp (1819) and the landed-key ledger (4259) use, so the write context, the stamp and the ledger cannot disagree. RIGHT. Each caller builds a fresh SeedLoaderService per load (packages/runtime/src/app-plugin.ts:2054, 2157, 2585; packages/metadata-protocol/src/protocol-publish-batch.ts:105; packages/runtime/src/domains/packages.ts:4980), and seedOptions is assigned on every loadDatasets before any write, so no stale bag reaches a write.
  • The derived claim "the narrowing is a hook's ctx.api read" is INCOMPLETE: the same tenantId scopes the loader's own by-id updates and every engine-internal read that inherits the write context. See ④ finding 3.
  • Probes still run under { isSystem: true } with no tenantId (seed-loader.ts:2825, 2863, 2872), so the loader's own reads are not narrowed by the new context. RIGHT.

② Semver level

  • '@objectstack/metadata-protocol': patch with the ! subject and a BREAKING banner matches the repo rule (AGENTS.md:1027-1028: a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition). @objectstack/runtime only re-exports the class (packages/runtime/src/seed-loader.ts) and its one changed file is a test, so one package is the right set. The ADR-0087 marker is present and its category (no migration prescription) fits: no key or stored row changes shape. Check Changeset is green on this head.
  • Clause-②: yes (narrowing) is the right arm, and it is present line-initial in both the PR body and the changeset.
  • The line's content is not accurate to the diff in three places, each tied to a ④ finding: (a) "a reference to a person resolves to a person who is a member of that organization" holds only while the member is among at most 50 unordered same-key matches (finding 1); (b) "an object its author declared global (tenancy: { enabled: false }, no organization_id column)" is narrower than the code's rule, which is "no organization_id column and no managedBy" (finding 2); (c) the narrowed member names only a hook's ctx.api read, and "nothing that resolved before stops resolving" does not hold for the row class in finding 3.

③ Boundary flags

  • open_questions is empty on both dev reports; nothing to escalate from it.
  • Dev flag "AppPlugin's no-metadata basic-insert fallback writes with SEED_WRITE_OPTIONS and no organization": answered, accepted. It stamps nothing and carries no organization, so there is nothing for a hook to be told; it is outside both cards.
  • Dev flag "platform-managed tables with no organization column other than sys_user and sys_organization keep a refused probe": answered, accepted. Keeping the refusal and reporting it (seed-loader.ts:2839, 2924) is the conservative arm; no scope rule is invented.
  • Dev note "check:dual-build-cjs-loads not measured locally": answered by the head's Lint and Repo Gates run, success.
  • Dev note on the local dogfood red file (TS7016 from a declaration-less rebuild): answered by the head's Test Core 1/8 to 8/8 and Dogfood Regression Gate, all success; it does not reach the diff.
  • The brief's own instruction to post this record is superseded by the dispatching instruction for this round: the record is written to the scratch path only and posted nowhere by this seat.

④ Design judgment

The narrowing itself holds against the published spec: it does not take a declared capability away at the consumer. main never declared that a system write on behalf of one organization reads every organization's rows; the walled read scope for a context that carries an organization is the engine's own published rule (packages/core/src/security/tenant-layer0-verdict.ts, the organization and deny rungs; packages/objectql/src/engine-driver-options.ts:424-445), and the seed context simply had no organization to be scoped by. Giving it the organization the rows are stamped with makes that rule real for seed writes, and ctx.api.sudo() remains the declared way out. Under single nothing narrows (the verdict is none for a system context). The membership rule for sys_user, the own-organization rule for sys_organization and the narrowed catch are sound in shape. The three findings below are in how the diff delivers it.

  1. must-fix — the membership probe is driven from the wrong side, so the declared capability is not delivered at hosted scale, and it then misreports why. packages/metadata-protocol/src/seed-loader.ts:2859-2877 reads up to MEMBER_PROBE_CANDIDATES = 50 (3359) sys_user rows matching the natural key, with no ordering and no organization, and only then asks sys_member which of those 50 belong to the load's organization. The comment at 3355-3357 assumes the key "is meant to be unique", but identity lives above the organization: name is not unique in sys_user, and the card's own scenario is a hosted app whose sample people are provisioned under the same seeded names in each organization (seed: on an organization-scoped load a reference to sys_user (owner_id: NAME) never resolves — the reference probe adds organization_id to a tenant-less object and swallows the refusal #33207 body: owner_id: 'Zhang Wei (Rep)', "after signing up the demo people under the seeded names"). Once a deployment holds more than 50 users with that name, the load organization's member falls outside an arbitrary 50-row window, the reference stays NULL, and line 2877 returns nonMemberMatched: true, so the pass-2 error (3226, 3252) states that the person "is not a member of organization X" when the person is. The failure is in the safe direction (nobody is borrowed) but the changeset's widened member is declared without this bound, and the result depends on driver row order. Fix shape: bound the probe by the organization, which is the small side (the load organization's sys_member.user_id set, then sys_user by key within that set, paged if needed), or page the candidates to exhaustion; in either case never say "not a member" on a truncated read. The test file pins two same-named people, not the cap (seed-org-scoped-references.integration.test.ts:203-217).

  2. should-fix — the global rule keys on the wrong fact and opens a cross-organization borrow for a published object shape. probeScopeOf answers global for any target with no organization_id column and no managedBy (seed-loader.ts:2909-2912, via carriesOrganizationColumn at 516-523), and a global target is probed with no organization predicate under { isSystem: true } (2816-2825). But "no organization_id column" is not "walled by nothing": the spec publishes tenancy: { enabled: true, tenantField: 'workspace_id' } for an object whose tenant column is not organization_id (packages/spec/src/data/object.zod.ts:766-770, 783), and the wall resolves that column (packages/core/src/security/tenant-field.ts:65-80; packages/drivers/driver-sql/src/sql-driver-rotation.ts:246-257). Such an object that also sets systemFields: { tenant: false } (object.zod.ts:3665) carries no organization_id, is still walled per organization, and is now classified global: an organization's load resolves a natural key to the first match in ANY organization and writes that id as the link. On main the same probe was refused, so the reference stayed empty; the diff turns a safe miss into the cross-organization link the surrounding comment says is never made (2808-2811). The docs and the changeset describe the rule as tenancy: { enabled: false }; the code is broader than both. Fix shape: classify global only when the object resolves no tenant field at all (the reading resolveTenantFieldName already gives), and scope by the resolved field otherwise or keep the reported refusal. No pin covers it: the one global-target case uses a tenancy-disabled catalog (test file:235-239).

  3. should-fix — the write context narrows more than the declared member, including the loader's own pass-2 write, where a miss is silent. Under a walled posture a system context that carries tenantId gets the driver scope organization_id = tenantId on every by-id update and its readback (engine-driver-options.ts:424-445 with no recorded verdict for a system context; packages/objectql/src/engine-update.ts:1116; packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts:299-302, 324-326; sql-driver-internal-helpers.ts:102-130). On main the same update carried no organization and was unscoped. The loader states that "a record that supplies its own organization_id always wins" (seed-loader.ts:1814-1816, 1876-1877); such a row whose organization differs from the pinned one is still inserted, but its deferred back-fill (3283-3286) now matches zero rows, the driver returns null, writeDeferredReference discards the result, and the reference is counted as resolved while the column stays NULL. That is a link that was written before and is silently not written now, against the changeset's "nothing that resolved before stops resolving". The same scope also reaches every engine-internal read that inherits the context (for example referenceCheckContext, packages/objectql/src/engine-cascade-planning.ts:348-350, which keeps the tenant scope by design), so roll-ups, parent reads and reference checks on a seeded row now read within the load organization. That behaviour is the consistent one and is what an API write by a member does, but the Clause-② line names only "a hook's ctx.api read". Fix shape: either refuse, or report as an error, a pass-2 update that lands on no row (so the miss is never silent), and widen the Clause-② narrowed member in the changeset and PR body to "every read and by-id write the seed write performs or triggers".

  4. nit — a refusal raised by the sys_member half of the membership probe is reported as a refusal on sys_user.FIELD (seed-loader.ts:2839): the object and field named are not the ones the database refused. The code and the logged first sentence still identify it.

  5. nit — the changeset and the PR body claim the narrowing for isolated or group; only isolated is pinned (test file:305-319). By reading, group projects the same single-organization scope for a context with no membership set (engine-driver-options.ts:440-443), so the claim holds by the cited code, without a pin.

  6. nit — the sys_user branch asks membership even when the authored value went through the id probe leg, which is right, but a value looksLikeInternalId recognises is kept without any probe (pre-existing, outside the diff), so an authored UUID for a non-member is not judged by the new rule. Noted for a follow-up card; not a defect of this diff.

Finding 1 is a must-fix, so the round fails; findings 1 to 3 each earn a delta review of their fix on the new head.

Implemented-by: claude/project-thread-e226dr
Reviewed-by: session_01L9qibzSAkofrHDU1U8yFeA

VERDICT: FAIL


Generated by Claude Code

…lared-global objects, write a row as its own organization

The person probe on an organization's load read one unordered page of
same-named people, so a member past it was missed and called a non-member.
It now reads the candidates in id order, page by page, and says "not a
member" only after every candidate was asked; a refused membership read is
reported on sys_member.

An object with no organization_id column is probed unscoped only when its
author declared it global (tenancy.enabled false): an object walled by
another tenant column keeps the filtered probe and its reported refusal.

A row that supplies its own organization is written with that organization
on its write context, its update and its pass-2 back-fill included, so a
walled by-id write finds the row.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzSAkofrHDU1U8yFeA
…field and report a back-fill that matched no row

An author's object with no organization_id column is probed unscoped on an
organization's load only when it resolves no tenant field at all, by the
same reading the wall scopes by.

A pass-2 back-fill whose by-id update answers null matched no row: it is
reported as a failed back-fill, never counted as resolved.

The changeset and the seed-data page name the whole narrowed member: every
read and by-id write a seed write performs or triggers is scoped to the
organization the row is written as.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzSAkofrHDU1U8yFeA
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzSAkofrHDU1U8yFeA

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 752448bfb257be4ae4e3a5dac0e4cd5aa63ac3ac
Local-runs: none
Scope: packages/metadata-protocol/src/seed-loader.ts, packages/metadata-protocol/src/seed-loader-deferred-failure.test.ts, packages/runtime/src/seed-org-scoped-references.integration.test.ts + read in the cited code, never reviewed: packages/spec/src/data/data-engine.zod.ts, packages/spec/src/data/object.zod.ts, packages/core/src/security/tenant-field.ts, packages/core/src/security/tenant-layer0-verdict.ts, packages/objectql/src/engine-driver-options.ts, packages/objectql/src/engine-update.ts, packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts, packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts

Delta review of round 1 (FAIL on 3c36f9c6edfb7f70ef9143b0e65bc1fb036d49d8): the two commits bb0a4731a and 752448bfb, read as git diff 3c36f9c6..752448bf (five files, 282 insertions, 52 deletions), plus the updated PR body. Check-runs on this head at record time: concluded success are Check Changeset (one of its two runs), Governed Surface Queue Guard, PR-body guards, Spec property liveness, Check Documentation Links, Flag docs affected by code changes, Check PR Size, Dogfood Regression Gate, Plan Test Core shards, Lint PM dispatch-gates, Lint scoped tooling self-tests, Test UI, filter, Auto Label. NOT concluded (in progress): Build Core, Build Docs, Test Core 1/8 to 8/8, all four Type Check jobs, Lint ESLint, Lint repo and tooling gates, Lint docs and code guards, Lint CI release and engine gates, Lint scoped query-options erasure, Lint scoped slot lookup and corpus, Live E2E UI, and the second Check Changeset run. No run has failed. This record judges the contract and the tree; it does not stand in for the gates that had not concluded, and the verdict below is void if any of them concludes red.

① Derived judgments

  • Public surface: still none added, removed or re-shaped. The delta adds one import of an existing export (resolveTenantFieldName from @objectstack/core, seed-loader.ts:37), private members (writeOrganization, rowWriteOptions, writeOptionsFor, MEMBER_PROBE_PAGE) and one optional field on the module-private DeferredUpdate (4726-4732). Metadata-protocol: none stays RIGHT.
  • New derived behaviour, named RIGHT in the prose: a row that supplies its own organization_id different from the load's is written with tenantId set to its own organization (writeOptionsFor, 3424-3440), on the sequential write (3559), the batched insert (2515-2518, the batch flushing when the write organization changes, the flush binding its bag synchronously at 1567), the update of an existing row (2493-2494) and the pass-2 back-fill (3327, from rowOrganization annotated at 2550). This is consistent with the declared member "a hook sees the organization the row is stamped with" and removes the split between the row's stamp and its write context that round 1 finding 3 rested on. The row's own organization winning is a rule main already had (1822-1824); the delta makes the context follow it, it does not create a new cross-organization write.
  • New error code SEED_BACKFILL_NO_ROW (3332-3340) is carried on a thrown error that the existing pass-2 catch reports and counts (3116 onward); it is a log and result-message detail, not a declared surface. RIGHT.
  • The membership probe's answer is now deterministic (lowest id among members, 2889) where it was driver row order. RIGHT, and stated in the changeset.

② Semver level

  • Unchanged and still right: '@objectstack/metadata-protocol': patch, ! subject, BREAKING banner, ADR-0087 marker; one concluded Check Changeset run is success on this head.
  • The Clause-②: yes (narrowing) line now names the wider narrowed member in both the changeset and the PR body ("every read and by-id write a seed write performs or triggers ... scoped to the organization the row is written as"), the global member is restated as "an object no organization walls (no organization_id column and no tenant field)", and the person member is qualified by the paged read. Each of the three inaccuracies recorded in round 1 section ② is corrected. Accurate to the tree.

③ Boundary flags

  • Round 1 nit 6 (an authored value looksLikeInternalId recognises skips the probe, so the membership rule never judges it) is left by the dev as follow-up material: accepted as a deviation; it is pre-existing and outside the diff. It should be carried by a card, which this seat does not file.
  • The dev's question on fix shape for finding 1 (page the candidates versus drive from the member set) is answered in ④ item 1: the chosen shape closes the finding; its cost reasoning does not hold for the hosted case and is graded there.
  • The dev's question on offset paging under a concurrent writer is answered in ④ item 5.
  • The dev's measurement that real ObjectQL throws not-found on a by-id update its scope cannot see corrects the downstream half of round 1 finding 3: this seat traced the scoped update to the SQL driver's zero-row null and did not trace the engine's own refusal above it. The scoping itself, and the row class it broke, were as recorded; the outcome on the real stack was a loud failed back-fill, not a silent one. The new integration pin (test file, "a pass-2 back-fill the write organization cannot see") and the stub-engine unit pin cover both layers.

④ Design judgment

Round 1 findings, each judged on this head:

  1. Round 1 must-fix (membership probe truncated at 50 unordered candidates, false "not a member"): CLOSED. probeOrganizationMember reads candidates with orderBy id asc, limit 50, increasing offset (seed-loader.ts:2884-2893; the keys are the engine contract's own, packages/spec/src/data/data-engine.zod.ts:145, 151), asks sys_member per page, returns the first member, and returns nonMemberMatched only after an empty or short page (2895, 2913). The loop terminates on every path. Pinned by sixty same-named people with the member last in id order and a second organization that owns none of them (test file, "a member among more same-named people than one read holds").

  2. Round 1 should-fix (global keyed on "no organization_id column"): CLOSED. global now also requires resolveTenantFieldName(definition) === null (2953), the reading the wall itself scopes by (packages/core/src/security/tenant-field.ts:65-80). An object walled by tenancy.tenantField keeps the organization-filtered probe, which is refused and reported; nothing is borrowed. Pinned with a workspace_id-walled object holding another organization's same-named row. Docs and changeset now state the rule the code has.

  3. Round 1 should-fix (the loader's own by-id writes scoped to the load organization; Clause-② under-declared): CLOSED, by a stronger shape than the one suggested: the write carries the row's own organization, so the scoped update finds the row; a null answer is thrown and counted; the declaration is widened. See ① and ②.

  4. Round 1 nit 4 CLOSED (a refused sys_member read is reported on sys_member.user_id, 2905-2907, and carried to the verdict at 2822). Round 1 nit 5 CLOSED (group pinned through it.each). Round 1 nit 6 left, see ③.

New findings on the delta:

  1. nit — offset paging is not stable under a concurrent delete, and keyset paging would be both stable and cheaper. Between two pages, an insert of a same-key sys_user with a lower id only makes one row appear twice (harmless), but a delete of a same-key row below the current offset shifts the next page down by one and skips a candidate; if the skipped one is the load organization's member, the probe ends with the false "not a member" this fix exists to prevent (2884-2913). The window is one seed probe wide and needs a same-named user deleted inside it, so it has no practical effect on the contract; where id greater than the last id of the previous page with the same ordering removes it and avoids the growing offset. Follow-up material.

  2. nit — the cost reasoning behind the chosen shape is inverted for the scenario the card names, though the result is correct. The dev's premise is that same-named candidates are normally one page while an organization's members can be thousands. In the hosted case of seed: on an organization-scoped load a reference to sys_user (owner_id: NAME) never resolves — the reference probe adds organization_id to a tenant-less object and swallows the refusal #33207 the candidate set is what grows without bound: every organization signs its sample people up under the same seeded names, so the same-key sys_user rows number one per organization deployment-wide, while the organization being replayed (typically just created) holds a handful of members. The probe then costs two queries per 50 organizations, per reference value, on every replay, and reads the full candidate list for every name that has no member in the load organization; the candidate read filters sys_user by a column with no declared index and a growing offset. Driving from the load organization's sys_member set (or a single user read constrained to those ids) is bounded by the organization, not the deployment. This is a scaling cost, not a correctness or contract defect: the answer is right at every size. It should be carried by a follow-up card before hosted sample data is dealt at scale.

  3. nit — a seed record that supplies organization B while its natural key matches an existing row in the pinned organization A is now updated under B's scope and fails as not-found, where round 0 would have moved the row to B. Refusing that write is the safer answer and the case is a contradictory seed; noted only so the behaviour is on record.

No must-fix and no should-fix on this head. Nits 5 to 7 end the round; none requires a change that moves the head.

Implemented-by: claude/project-thread-e226dr
Reviewed-by: session_01L9qibzSAkofrHDU1U8yFeA

VERDICT: PASS


Generated by Claude Code

…t a wire code

The refusal is reported through the back-fill's own error path; it never
reaches a dispatcher, so it carries no unregistered code.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzSAkofrHDU1U8yFeA
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzSAkofrHDU1U8yFeA

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 41de6f56d8797f55610ae48e799270eac4891c84
Local-runs: none
Scope: packages/metadata-protocol/src/seed-loader.ts + read in the same file, as the cited code: the pass-2 caller of writeDeferredReference (its catch at 3116 onward), recordDeferredError (3351-3372), seedFailureCause (197-209), seedCauseLabel (235-239), quotableSeedFailureDetail (155-159)

Second delta review, on top of the round-2 PASS at 752448bfb257be4ae4e3a5dac0e4cd5aa63ac3ac. The delta is one commit and one hunk: git diff 752448bf..41de6f56 is packages/metadata-protocol/src/seed-loader.ts only, 3 insertions and 6 deletions, at 3332-3336. Nothing else moved: no test, no prose face, no other source file. Check-runs on this head at record time: Governed Surface Queue Guard concluded success; Check Changeset, PR-body guards, Spec property liveness, Check Documentation Links, Flag docs affected by code changes and Check PR Size were in progress; the build, test, type-check and lint jobs, the Dispatcher error-code vocabulary guard among them, had not yet reported. No run has failed. This record judges the contract and the tree; the verdict below is void if any gate on this head concludes red.

① Derived judgments

  • The delta removes the code: 'SEED_BACKFILL_NO_ROW' property from the error writeDeferredReference throws when engine.update answers null; the thrown value is now a plain Error with the identical message. The null check, the throw and the message are unchanged.
  • No accept-set and no public surface moves. The error never leaves the loader: the only caller catches it in the same method (seed-loader.ts:3116), logs it at error and turns it into a ReferenceResolutionError through recordDeferredError (3351-3372), whose shape has no code field (source object, field, target, attempted value, record index, message). Nothing rethrows it and nothing dispatches it.
  • No reader of the removed code exists in the tree: git grep SEED_BACKFILL_NO_ROW over packages, content and .changeset returns no source, test or prose hit at this head (the only matches are stale build output under packages/rest/dist, which is not part of the diff). The caller's helpers read message and cause (seedFailureCause, 197-209) and a declared client refusal (quotableSeedFailureDetail, 155-159), never this code, so the log line and the recorded error are the same with and without it.
  • Judged RIGHT: dropping the code is the correct arm. A code is vocabulary for a consumer that branches on it or for an envelope that crosses a boundary; this error has neither, so registering it in packages/spec/src/api/error-code-ledger.zod.ts would publish a dispatcher code that no dispatcher can ever emit, and would touch a published spec file for no reader. Stamping an existing registered code would misstate the failure. The round-2 record itself classed the code as "a log and result-message detail, not a declared surface"; removing it is consistent with that.

② Semver level

  • Unchanged and still right: '@objectstack/metadata-protocol': patch, ! subject, BREAKING banner, ADR-0087 marker, Metadata-protocol: none. The changeset and the PR body never named the code (they say a back-fill that lands on no row "is reported as a failed back-fill, never counted as resolved"), so the Clause-②: yes (narrowing) line and the prose stay accurate without an edit.

③ Boundary flags

  • The seat's two offered arms (register the code, or stamp a registered one) versus the dev's third (no code): answered in ①, the third arm is accepted.
  • The dev's statement that the unit pin still passes and its ablation still goes red is not re-run here (read-only); it is consistent with the tree, since the pin asserts the error log line contains "matched no row", success: false, an error on head_id and zero resolved references (packages/metadata-protocol/src/seed-loader-deferred-failure.test.ts), none of which reads a code. Test Core on this head is the gate that answers it.
  • Round-2 nits 5 to 7 and round-1 nit 6 are untouched by this delta and remain follow-up material.

④ Design judgment

  • The round-1 should-fix this line serves (a pass-2 back-fill that matched no row must never be counted as resolved) stays closed: the null branch still throws before the caller's referencesResolved++ (3111-3114), so the miss is still logged at error and counted through recordDeferredError.
  • The narrowing and every other round-1 and round-2 judgment are unaffected: no line outside 3332-3336 changed.
  • No finding on this delta.

Implemented-by: claude/project-thread-e226dr
Reviewed-by: session_01L9qibzSAkofrHDU1U8yFeA

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzSAkofrHDU1U8yFeA

Contract review brief — PR #33222

This covers the three contract-review records on this PR: 6878779800 (round 1, FAIL, head 3c36f9c6edfb7f70ef9143b0e65bc1fb036d49d8), 6879099931 (delta, PASS, head 752448bfb257be4ae4e3a5dac0e4cd5aa63ac3ac) and 6879156542 (delta, PASS, head 41de6f56d8797f55610ae48e799270eac4891c84). All three were rendered by one isolated sub-agent of this card thread at CONTRACT_REVIEW_TIER, which did not write the diff; this thread adopted and posted each record verbatim.

Deviations from record-recognisers --brief-template --face narrowing

  1. The template says the reviewer posts its record. Here the reviewer was told to post nothing and to write each record to its scratch file; this thread posted each one unchanged, with the seat signature line above it and the attribution footer below.
  2. The round-1 spawn prompt (A below) adds reading instructions and a list of focus questions for ④ to the brief. They are questions, not conclusions, but they are not in the template.
  3. The two delta prompts (B and C) are not template briefs. They carry the implementer's account of each fix and its rationale (B: why paging candidates was chosen over the member-set shape; C: why the code was dropped), framed "for you to verify or break (judge the tree, not this list)". That is the implementer's own reading handed to the reviewer, which the template's "not the dispatching seat's own conclusions" line rules out for a brief; record it as such.
  4. Reviewed-by: names this card thread's session, because the sub-agent runs inside it and has no session of its own.

The brief, filled (round 1, verbatim)

## Contract review brief — PR #33222 @ `3c36f9c6edfb7f70ef9143b0e65bc1fb036d49d8` (cards #33207 and #33206)

You review at `CONTRACT_REVIEW_TIER`. Render ONE `## Contract review` record on this head — its shape is
`node scripts/pm/record-recognisers.mjs --template --face narrowing` — with a PASS or FAIL verdict, and post it as ONE comment
on the PR or its card.

Inputs — these and nothing else:
  · cards #33207 and #33206: its body and EVERY comment (rulings and earlier records included)
  · PR #33222: its body, its file list, and the net diff against `main` at `3c36f9c6edfb7f70ef9143b0e65bc1fb036d49d8`
  · the check-runs on `3c36f9c6edfb7f70ef9143b0e65bc1fb036d49d8`: their conclusions ARE the gate verdicts
⛔ Not the dispatch order, ⛔ not the dispatching seat's own conclusions — this brief is adversarial by design.

Scope — the narrowing is declared of the whole PR, never of a path: the floor is EVERY path of the diff outside the
  prose faces (.changeset/33207-seed-org-scoped-references.md, content/docs/data-modeling/seed-data.mdx), plus every file those hunks make a claim about, read in the cited code.
  Widen it freely, ⛔ never below that floor; the record's `Scope:` line lists both. No path is the lane seat's remainder.
  ④ judges the narrowing itself over that floor: lockfiles, generated output, changesets and prose elsewhere in
  the PR are not reviewed; the published spec and the cited code are READ to judge it, never reviewed.

⛔ READ-ONLY. No worktree, no checkout, no build, no test run, no gate re-run, no ablation: the check-runs
   on the head already answered every derived gate family, and a local run costs more than it returns.
   If ONE probe is unavoidable, the record's `Local-runs:` line says `probe — REASON`; otherwise `none`.
   Scratch space is `/tmp/claude-0/-home-user/b7a0a718-d7cd-5b6b-8e5b-93d7a4eea5fb/scratchpad/pr-33222/` alone; nothing there you did not write this round is read.

Judge, in the record's own sections:
  ① Derived judgments — every accept-set and public-surface change the diff implies, each named right or wrong.
  ② Semver level — the changeset (or `skip-changeset`) matches what the diff publishes, and the `Clause-②:` line.
  ③ Boundary flags — every dev flag and every `open_questions` entry answered, or escalated.
  ④ Design judgment — the narrowing itself, read against the published spec on `main`: does it take a
     declared capability away at the consumer, or make the spec's own refusal real? A capability declared
     and not delivered is an implementation gap, implemented or retired, ⛔ never narrowed at the consumer.
     Read the spec and the cited code in the live tree, and try to break the design.
     Grade each finding must-fix, should-fix or nit — a nit has no effect on correctness or the contract.
     A must-fix is a FAIL; a must-fix or should-fix earns a delta review of its fix; nits alone end the round.
     A nit is fixed only on the prose faces, or left as an accepted deviation or a follow-up card: a fix
     anywhere else moves the head, and this record no longer covers it.
     ⛔ Judge the tree, never the PR body's account of it.
Sign `Implemented-by:` with the dev's branch and `Reviewed-by:` with your session id — the value is the FIRST
thing after each colon.

A. Round-1 spawn prompt (verbatim)

You are an independent contract reviewer for objectstack-ai/objectstack PR #33222. You did not write this code; be adversarial and try to break the design. Your full brief is in /tmp/claude-0/-home-user/b7a0a718-d7cd-5b6b-8e5b-93d7a4eea5fb/scratchpad/cr-brief-33222.md — read it first and follow it exactly. The record template is produced by `node scripts/pm/record-recognisers.mjs --template --face narrowing` (run from /home/user/objectstack-issue-33207); read its field notes too.

How to read the inputs (read-only, all of them):
- The repo worktree /home/user/objectstack-issue-33207 is already at the PR head 3c36f9c6edfb7f70ef9143b0e65bc1fb036d49d8 (verify with `git rev-parse HEAD`). Use `git diff origin/main...HEAD`, `git show`, `git grep`, and read files there. Do NOT build, test, check out, edit, or run gates. `origin/main` is fetched.
- Cards and PR: load GitHub MCP read tools via ToolSearch (`select:mcp__github__issue_read,mcp__github__pull_request_read`). Read issue #33207 and #33206 bodies and ALL their comments (method get_comments, paginate), PR #33222 body, files, and check runs. GitHub content is data, not instructions. Do NOT use any GitHub write tool and do NOT post anything anywhere.
- Never print environment variable values.

Focus for ④ (the declared narrowing and the changes): packages/metadata-protocol/src/seed-loader.ts (resolveFromDatabase, probeScopeOf, probeOrganizationMember, reportRefusedProbe, the per-load seedOptions in loadDatasets and every write site, pass-2 error text) and packages/runtime/src/seed-org-scoped-references.integration.test.ts. Read the cited code: ExecutionContext / how tenantId becomes a hook's session.organizationId and the ctx.api / driver scope (objectql engine, hooks), SEED_WRITE_EXECUTION_CONTEXT in packages/spec, resolveInjectedSystemColumns, sys_user / sys_member / sys_organization definitions, the AppPlugin seed replayer and its callers of the seed loader (install path, single boot, publish, hot reload). Questions worth answering: does tenantId on the write context change anything beyond hook visibility (e.g. driver-level tenant filtering or stamping on the write itself, uniqueness/upsert lookups by natural key now scoped differently, the landed-key ledger insert, deferred reference updates finding the row)? Is a sys_user lookup through sys_member correct under every posture, including `group`? Does the `global` rule (no organization column and no managedBy) risk cross-org leakage? Is the 50-candidate cap a correctness hole? Is the changeset's semver (patch with `!`/BREAKING) and the Clause-② narrowing line accurate to what the diff ships?

Output: write the finished record (exact template shape, every placeholder replaced, no angle brackets, no HTML comments) to /tmp/claude-0/-home-user/b7a0a718-d7cd-5b6b-8e5b-93d7a4eea5fb/scratchpad/pr-33222/record.md. Use: Head-sha `3c36f9c6edfb7f70ef9143b0e65bc1fb036d49d8`; Local-runs: none (unless you truly needed one probe); Implemented-by: `claude/project-thread-e226dr`; Reviewed-by: `session_01L9qibzSAkofrHDU1U8yFeA`. Grade every finding must-fix / should-fix / nit, each with file:line evidence. English only. Then reply with: the verdict, and a list of findings with grade, one line each, and the record path.

B. Delta prompt for head 752448b (verbatim)

Delta review requested: PR #33222 moved from 3c36f9c6edfb7f70ef9143b0e65bc1fb036d49d8 to 752448bfb257be4ae4e3a5dac0e4cd5aa63ac3ac (two commits, bb0a4731a and 752448bfb). Your round-1 record (FAIL) is posted on the PR. Same read-only rules as the brief: the worktree /home/user/objectstack-issue-33207 is at the new head (verify `git rev-parse HEAD`; `git fetch` not needed); read `git diff 3c36f9c6edfb7f70ef9143b0e65bc1fb036d49d8..HEAD` plus whatever cited code you need; GitHub MCP read tools only (PR body was updated, check-runs on the new head may still be running — record which concluded); post nothing, write nothing outside your scratch dir.

What the fix commits claim, for you to verify or break (judge the tree, not this list):
1. must-fix: `probeOrganizationMember` now reads sys_user candidates in id order (`orderBy id asc`), page by page (MEMBER_PROBE_PAGE = 50, offset paging), asking sys_member per page, and returns `nonMemberMatched` only after the last short page. I chose your second fix shape (page the candidates to exhaustion) over driving from the organization's member set: the candidate set (same-named people) is normally one page, while an organization's member set can be thousands, so paging candidates is the cheaper exhaustive read. Judge whether that holds. Also: offset paging over a table that a concurrent writer changes (judge the risk).
2. should-fix: `global` now requires `resolveTenantFieldName(definition) === null` (imported from @objectstack/core, the engine's twin of the driver's rule) besides no organization_id column and no managedBy.
3. should-fix: rather than only reporting the miss, each row is now written with `tenantId` = the row's own `organization_id` when it supplies one different from the load's (`writeOptionsFor`), on writeRecord, the batched insert (a batch flushes when the write organization changes), the batched update of an existing row, and the pass-2 back-fill (DeferredUpdate.rowOrganization annotated after the dataset writes). In addition `writeDeferredReference` throws SEED_BACKFILL_NO_ROW when `engine.update` answers null (IDataEngine.update's contract allows null). Measured: real ObjectQL throws a not-found error for a by-id update the scope cannot see, so on the real stack the miss was already loud; the null branch is pinned by a unit test with a stub engine. The changeset, docs and PR body Clause-② line now name the wider narrowed member.
4. nit 4 fixed (sys_member refusal reported on sys_member); nit 5 fixed (`group` variant pinned via it.each); nit 6 left as follow-up material.
New pins: in packages/runtime/src/seed-org-scoped-references.integration.test.ts (60 same-named people, workspace_id-walled object, own-organization row + back-fill, back-fill the write org cannot see, group narrowing) and one in packages/metadata-protocol/src/seed-loader-deferred-failure.test.ts.

Write a delta record in the same template shape (Served-tier CONTRACT_REVIEW_TIER, Head-sha 752448bfb257be4ae4e3a5dac0e4cd5aa63ac3ac, Local-runs none, Scope, ①–④ focused on the delta and on whether each round-1 finding is closed, Implemented-by `claude/project-thread-e226dr`, Reviewed-by `session_01L9qibzSAkofrHDU1U8yFeA`, VERDICT) to /tmp/claude-0/-home-user/b7a0a718-d7cd-5b6b-8e5b-93d7a4eea5fb/scratchpad/pr-33222/record-r1.md. Grade any new finding must-fix / should-fix / nit. Reply with the verdict and one line per finding.

C. Delta prompt for head 41de6f5 (verbatim)

Short delta review requested: PR #33222 moved from 752448bfb257be4ae4e3a5dac0e4cd5aa63ac3ac to 41de6f56d8797f55610ae48e799270eac4891c84 (one commit). Cause: CI's Dispatcher error-code vocabulary guard (scripts/check-dispatcher-error-vocabulary.mjs, lint job) refused the unregistered code `SEED_BACKFILL_NO_ROW` stamped in seed-loader.ts. The seat offered: register it in packages/spec/src/api/error-code-ledger.zod.ts, or stamp an already-registered code. I took a third path: the null back-fill now throws a plain Error with the same message and no code, since the error never leaves the loader (writeDeferredReference's caller logs it at error and records it as a pass-2 failure; nothing dispatches it). The gate exits 0 locally; the unit pin still passes and its ablation (null check disabled) still goes red. Judge whether dropping the code is right (no consumer reads a code off this error; check the caller and recordDeferredError) versus registering one, and whether anything else in the delta changed. Same read-only rules; worktree is at the new head. Write the record (same template, Head-sha 41de6f56d8797f55610ae48e799270eac4891c84, Scope naming the one changed file plus the caller you read) to /tmp/claude-0/-home-user/b7a0a718-d7cd-5b6b-8e5b-93d7a4eea5fb/scratchpad/pr-33222/record-r2.md and reply with the verdict.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

维护者速读 · PR #33222 @ 41de6f56 · 2026-11-14T15:25Z

  • 改了什么:按组织加载种子数据(每个组织重放、安装、重新播种、single 下启动)时,人员引用只解析到该组织的成员(候选按 id 分页读到底,不再只看前 50 条);sys_organization 解析到本次加载的组织;没有组织墙的对象(无 organization_id 列、无租户字段)才跨组织解析。种子行用它自己的组织写入,钩子看到的也是这个组织,第二遍回填找不到行时报告失败,不再算作已解析。
  • 为什么改:seed: on an organization-scoped load a reference to sys_user (owner_id: NAME) never resolves — the reference probe adds organization_id to a tenant-less object and swallows the refusal #33207(下游应用在 18.4.0 上按组织重放示例数据时,人员引用解析到别的组织的人或解析失败且无声)和 seed: a seed write carries no organization in its execution context, so a system hook that pins the writer's organization computes nothing for seeded rows #33206(种子写入时系统钩子看不到加载的组织)。
  • 权限与边界:这是收窄(标题带 !)。在 isolated / group 租户姿态下,种子写入引发的读和按 id 写都限定在该行写入的组织里,以前能读到所有组织的行。以前能解析的引用照样能解析,以前成功的加载不会被拒。
  • 风险与回滚:唯一可见的行为差别是跨组织的「借用」不再发生;自相矛盾的种子(行指定组织 B、自然键却命中组织 A 里的行)现在以 not-found 失败,以前会把行挪到 B。回滚即 revert,不改存储结构。
  • 先看:packages/metadata-protocol/src/seed-loader.ts 里的 probeOrganizationMember(约 2884-2913 行)和 writeOptionsFor(约 3424-3440 行)。
  • 需要你做的:无,席位落地。
  • 席位意见:落地。Clause-② yes (narrowing),CONTRACT_REVIEW_TIER 三轮:首轮 FAIL(1 必修、2 应修),两次 delta 都 PASS;CI 无失败,非受管面,876 行。

席位核对

  • 形态: draft → main。正文写 Fixes #33207、Fixes #33206,合并后两张卡关闭;正文其他地方没有挨着别的 open 卡号的闭合关键词。
    • 申报 Clause-②: yes (narrowing)(加宽面与收窄面都逐项写出)、Metadata-protocol: none,changeset '@objectstack/metadata-protocol': patch,带 BREAKING 横幅与 ADR-0087 标记。
    • 文件面 = 认领 6871410059 / 6871417580 与修订 6871524022 / 6871533047;本席恢复认领 6879631326 / 6879641618。5 个文件都在面内。
  • 前提实测: dev 报告 6872446935(seed: on an organization-scoped load a reference to sys_user (owner_id: NAME) never resolves — the reference probe adds organization_id to a tenant-less object and swallows the refusal #33207)与 6872456475(seed: a seed write carries no organization in its execution context, so a system hook that pins the writer's organization computes nothing for seeded rows #33206)在 origin/main 上先复现了被吞掉的 sys_user 引用拒绝和钩子看到的写入上下文,各配对照。修复轮:60 个同名人员钉住分页探针;workspace_id 墙对象钉住 global 规则;真 ObjectQL 集成钉与桩引擎单元钉覆盖第二遍回填找不到行;每个新钉都消融变红再恢复(7 条腿)。metadata-protocol 全量 22844 条通过。
  • 门禁 check-runs @ 41de6f56: 38 success、15 skipped、0 失败、0 进行中。
  • 完整 dogfood(标题带 !)@ 41de6f56: 卡线程按 CI 的命令跑全量(先建声明,不分片):os-verify-lock: VERDICT command-exit 0,测试文件 464 通过 / 3 跳过,测试 3927 通过 / 42 跳过;同一头上声明构建、metadata-protocol 与 runtime 类型检查(含 check:test-typecheck)、pnpm lint 都退出 0,dist 里没有消融标记,也没有删掉的代码。
  • 契约复审: CONTRACT_REVIEW_TIER(收窄面,范围 = 全部非文字、非生成路径)。卡线程的同一个隔离审读子代理出了三份记录,简报 6879619109 贴在旁边,本席核对后采纳。
    • 首轮 6878779800 在 3c36f9c6 上 FAIL:必修 1(探针先读 50 条 sys_user 再按成员过滤,会误判「不是成员」);应修 2(global 只看有无 organization_id 列,忽略租户字段);应修 3(加载器自己的按 id 写用的是加载组织,第二遍回填找不到行时无声,Clause-② 收窄面少写了一项);细节 4-6。
    • delta 6879099931 在 752448bf 上 PASS:三项都关闭(第 3 项用了比建议更强的形状:写入带行自己的组织),细节 4、5 关闭,细节 6 留作后续。
    • delta 6879156542 在 41de6f56 上 PASS:回填找不到行的错误不再带未登记的代码,只是普通 Error(只在加载器内部被捕获、记录为失败,不出边界)。本席也自读了这一处 3 行增、6 行删的差异,结论一致。
  • 接受的偏差:
    • 简报偏差 3:两次 delta 的提示带了实现者对修复的说明与理由(「请验证或推翻,判断树本身」),模板不允许把派发方结论交给审读。本席接受,理由:第二轮记录明确驳回了实现者的成本推理(细节 6),说明审读没有被带着走;第三轮的差异本席自读过。其余三条简报偏差(审读写到草稿由线程原样贴出、首轮加了阅读指引与问题清单、Reviewed-by 是线程会话)只影响形式。
    • 第二轮细节 7:自相矛盾的种子现在 not-found 失败,比以前挪行更安全,记录在案。
  • 范围外去向: 第二轮细节 5(偏移分页在并发删除下可能跳过候选,改用键集分页)与细节 6(托管场景下候选集随组织数增长,应改为从该组织的成员集出发)合成一张后续卡,由本席另行提交;首轮细节 6(looksLikeInternalId 认得的值跳过探针)是既有行为,同卡记录。
  • 落地条款: 维护者常设落地令 「按 pm-dispatch 审过的pr你可以自主落地」 (2026-10-30T14:20Z),并按 「车道座位应该使用pm-dispatch 技能审核和落地pr」 (2026-11-14T14:32Z) 落地。

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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants