Skip to content

metadata-protocol(seed-loader): an organization-scoped load inserts a twin of every row whose natural key a NULL-organization row already holds, silently, instead of refusing it (leg B of #24282) #24301

Closed

Description

@objectstack-fleet

carries leg B of #24282 (the seed loader's refusal); #24282 keeps leg A (the migration ceremony and the boot guard). Filing gate ①, split from a queued card under triage 6248107364 ("If the lane splits them, leg B is filed as a domain:engine card, cross-linked here, at the split"). The lane and grade are triage's at first touch: this card carries no domain:* label by rule.

Filed by the domain:cli seat 1 (#6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T, at the split, before claiming #24282's leg A. ⛔ Not a claim.

reach: #24282's measurement (a HotCRM 4.0.1 → 5.0.0 upgrade on one SQLite file). An organization-scoped load through the runtime's seed-replayer service, called for the Default Organization through @objectstack/verify bootStack, inserted 150 rows with 0 errors over rows whose organization_id was NULL:

  • crm_event went from 29 to 58 rows on 29 subjects;
  • crm_task from 7 to 14, crm_event_attendee from 29 to 58, crm_quote_line_item from 20 to 40, and crm_opportunity_line_item gained 61.

Control: the same call over a fully stamped database inserted 0. The user-visible result is objectstack-ai/hotcrm#2146: every seeded event on Sales Home appears twice on the hosted environment jack.

Producer (read on origin/main d537a318b)

packages/metadata-protocol/src/seed-loader.ts, loadExistingRecords, line 3138: if (organizationId) findArgs.where = { organization_id: organizationId };. A row whose natural key matches but whose organization_id is NULL is invisible to that scoped lookup. decideWriteAction then returns insert, and nothing is logged.

Done when

Relationship

Dedupe

#24282's dedupe stands (MCP search_issues, closed included): "seed loader organization scoped upsert inserts twin of existing row" gave 17 hits, and none covers the scoped loader's twin insert. The nearest is #12852 (resolveSoleOrganizationId seeds org-less rows, closed). This card is that card's leg, split by rule, not a new finding.

Dedupe words: seed loader organization scoped twin insert; loadExistingRecords organization_id NULL invisible; decideWriteAction insert duplicate seed.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p1 · target:v18 · area:access · domain:engine · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-18T11:00Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.


    Generated by Claude Code

  2. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    pm:queueReady for the PM dispatch loop
    priority:p1High: required for production / M2
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Oct 18, 2026
  3. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 7 · 2026-10-18T15:02Z · after triage 6249042739 graded this card p1 target:v18 (leg B of #24282, whose leg A landed as PR #24325, 51ad8e4fb). An organization-scoped seed load refuses a row whose natural key a NULL-organization row already holds, with a named error in the seed result, instead of inserting a twin. It does not adopt the row.
    Session: session_01JtSQQ3EWsbNPzyYMug1tXy (the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)
    Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24301-seed-null-org-twin-refused
    Worktree: objectstack-issue-24301
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface:


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T15:04Z: mode:cloud. The dev session is session_01HsiLAicj3hYCseWnNxYkCj. It builds at the default tier on branch claude/issue-24301-seed-null-org-twin-refused, claim 6251425947.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24301,
    "status": "done",
    "branch": "claude/issue-24301-seed-null-org-twin-refused",
    "pr": "#24372",
    "session": "session_01HsiLAicj3hYCseWnNxYkCj (this cloud dev session; dispatched by session_01JtSQQ3EWsbNPzyYMug1tXy, claim 6251425947, whose Claim: names this branch)",
    "premise_still_valid": true,
    "summary": "An organization-scoped seed load (config.organizationId, upsert or ignore) now refuses a seed record whose natural key a NULL-organization row holds, as a per-row error through the existing path, instead of inserting a twin; the row is never adopted and the rest of the dataset still loads. seed-loader.ts: loadExistingRecords takes an optional unownedHolders map that a second read (where organization_id null, system context) fills with keys only unowned rows hold, kept out of the returned map so no caller matches such a row as the organization's own; decideWriteAction gains a refuse answer; the scoped caller asks it once per record after reference resolution and before both write paths. M1 reproduced on a real SQLite file before the fix (tw_event 2 to 5, tw_task 1 to 3, all 5 seed rows inserted, 3 twins, 0 refusals); after: 2 to 3 and 1 to 2, 3 named refusals, unowned rows byte-identical. H1 holds with a changed shape (a separate NULL read, not a widened query), measured on SQLite, memory and a live Postgres 16. H3 refined: the two rechecks keep reading the organization's rows only (a row an earlier attempt committed carries the organization), and a refused row never reaches the insert buffer they guard.",
    "tests": "Head 7cfdef0 (after merging origin/main a2a3777). Build: pnpm --filter '@objectstack/runtime^...' --filter @objectstack/runtime build exit 0. pnpm --filter @objectstack/metadata-protocol test (pre-merge head): 245 files passed, 3 skipped; 20649 tests passed. New unit file seed-loader-null-organization-holder.test.ts 8/8; new real-driver file packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts 6/6 on the merged head. Runtime seed suites (src/seed*, app-plugin.seed*, app-plugin.disabled-seed, autonumber-seed*, standalone-stack-seeder*): 16 files, 158 tests passed; seed-replay-per-organization-identity.integration 6/6 alongside. M1 reproduction before the fix: 5 failed / 6 passed with 'expected length 3 but got 5' and 'expected 5 to be 2'. Postgres one-off: the pin's four scenarios run temporarily on client 'pg' against a local PG 16 cluster I started and stopped: 4 passed, rows read back with psql; the mutation was restored with git checkout HEAD and the tree was clean. Memory driver: the same four passed in an earlier revision of the pin (11/11), then removed for check:driver-memory-census. Ablation (one line): the refuse return in decideWriteAction gated off with scripts/ablation-replace.mjs, rebuilt, ablation-dist-preflight marker present in 2 dist files; unit file 5 failed / 3 passed, real-driver file 4 failed / 6 passed (tw_event length 5, inserted 5); restored with git checkout HEAD, blob hash 2cfc2e866 equal to HEAD, rebuilt, marker absent from 24 dist files (--absent exit 0), tree clean, real-driver file 10/10.",
    "gates": "Local: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack with no paths on 7cfdef0 derived 73 commands (dispatch's 47-command lead was on the single path; the merge base derivation gave 64 before the ledger file joined the diff, 73 after). All 73 ran, exit code captured before any pipe, all exit 0. --ran: 'Run reconciliation, 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN' and 'dispatch-gates --ran: 73 derived famil(ies) accounted for, 73 run, 0 NOT-MEASURED (a DERIVED zero, all 73 recorded an exit code and none of them is 3)'. Readings on the way: check:engine-double-contract exit 1 (new fake not ledgered; fixed with its own --write, 3 rows); check:query-options-erasure exit 1 (an untyped options bag; now EngineQueryOptions); check:driver-memory-census exit 1 (a new memory-driver test consumer; removed); check-plugin-teardown-shape --self-test exit 3 on the shallow clone (prerequisite; fixture commit fetched, then exit 0); check:dual-build-cjs-loads exit 3 before the full runtime closure was built (prerequisite), exit 0 on the final head; check:query-options-erasure and check:type-check-debt killed by a 280 s cap (exit 124), re-run alone: both exit 0. Typecheck per touched package: @objectstack/metadata-protocol exit 0; @objectstack/runtime exit 0 (check:test-typecheck OK, the new test is in the test-layer program, 0 errors). CI families: in_progress at report time (PR just opened); the seat reads the check-runs on the PR. Not run locally: pnpm lint and test:repo (CI's).",
    "line_budget": "none set. +/- per file vs origin/main a2a3777: .changeset/24301-seed-null-organization-holder-refused.md +19/-0; packages/metadata-protocol/src/seed-loader.ts +94/-2; packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts +241/-0; packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts +304/-0; scripts/engine-double-contract.pinned.json +15/-0. Total +673/-2.",
    "files_changed": [
    ".changeset/24301-seed-null-organization-holder-refused.md",
    "packages/metadata-protocol/src/seed-loader.ts",
    "packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts",
    "packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts",
    "scripts/engine-double-contract.pinned.json"
    ],
    "deviations": [
    "File surface: two paths outside the claim's list. (1) The real-driver pin lives in packages/runtime/src, not beside seed-loader.ts: @objectstack/objectql depends on @objectstack/metadata-protocol, so the loader's package cannot reach a real engine and driver without a cycle, and packages/runtime/src is where the existing real-driver seed pins live (seed-loader-driver-text-real-driver, seed-replay-per-organization-identity). It is a new test file only; no runtime source, no seed-replayer, no @objectstack/verify. (2) scripts/engine-double-contract.pinned.json, 3 rows written by the gate's own --write for the new test's fake (the gate's instruction for new pinned coverage).",
    "Changeset bump is minor with a BREAKING section (launch-window convention for a Clause-② narrowing, as other narrowing changesets in .changeset/ do).",
    "git push ran 5 times to this branch (empty branch, 3 commits, the main merge): os-dev's commit-and-push per compilable step; none is a REST write.",
    "The shared primary checkout was on this branch at start; it was detached to let the worktree take the branch, and re-checked-out on the branch after the worktree was removed. No edit was made in it.",
    "Postgres was measured once, not pinned: a permanent PG case would need live-dialect skip machinery the runtime suite does not carry. The memory driver was measured and then dropped from the pin for check:driver-memory-census."
    ],
    "mcp_calls": "0: no MCP GitHub tool was called (issue, comment and PR reads went through plain REST GETs).",
    "api_writes": "2 strokes through the fleet-write relay as objectstack-fleet[bot]: (1) pr_create run 39147049885: POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24372/assignees [os-project-manager], read back 9644 bytes sent and stored, identical; (2) comment: POST /repos//issues/24301/comments (this os-dev-report). Plus git push to the branch (not REST). No labels written (none named; the PR publishes, so no skip-changeset).",
    "open_questions": [
    {
    "question": "Does the existing ReferenceResolutionError carry this refusal honestly, or does it need a discriminating field (domain:spec)?",
    "options": [
    "A: reuse it as is: field 'organization_id', targetObject the seeded object itself, targetField the natural key, attemptedValue the key, a message naming the holder and the remedy (what landed)",
    "B: add a discriminator such as a 'kind' to ReferenceResolutionErrorSchema"
    ],
    "recommendation": "A. Business need, measured: the readers are the replayer's and runInlineSeed's error counts and samples, which read errors.length and message and never branch on a kind. Long term: the shape stays one contract; the refusal is told apart structurally (field organization_id plus targetObject equal to sourceObject; the unscoped no-owner refusal names sys_organization), and the pins read it that way. AI error-proofing: the refusal is loud (errored, success false, error log) and names its remedy, so nothing hides it. Startup focus: no spec change without a consumer that would read it. No schema change is reported to domain:spec."
    },
    {
    "question": "Should the two rechecks (writeBatchPartial's retry and writeOne's) treat a NULL-held key as already present (the dispatch's H3)?",
    "options": [
    "A: no: they keep reading the organization's rows only, and a refused row never reaches the insert buffer they guard (what landed)",
    "B: yes: pass the unowned map to the rechecks too"
    ],
    "recommendation": "A. The rechecks answer 'did an earlier attempt of THIS load commit the row?', and such a row carries the organization. B would return the unowned row as the inserted outcome: its id would enter the reference map and the seed-settlement report, an adoption by another door. Pinned: the retry's one recheck read is organization_id = ORG, and no insert payload carries a held key, on both the retry and the writeOne paths."
    },
    {
    "question": "Should update mode (and insert/replace) also refuse a NULL-held key?",
    "options": [
    "A: no: update never inserts, so it makes no twin and keeps skipping an unmatched key; insert/replace never pre-load (what landed)",
    "B: refuse in every mode"
    ],
    "recommendation": "A. The card's Done when is the twin insert. update mode writes no new row, and refusing there would widen a narrowing the card did not ask for. Pinned: update mode makes no NULL read and reports nothing. Noted in the PR's Acceptance notes."
    },
    {
    "question": "Placement of the real-driver pin (packages/runtime/src, outside the claim's file list)",
    "options": [
    "A: keep it in packages/runtime/src beside the existing real-driver seed pins (what landed)",
    "B: add objectql and driver-sql as metadata-protocol devDependencies"
    ],
    "recommendation": "A. B is a dependency cycle (objectql depends on metadata-protocol). The seat may want to amend the claim's File surface to name the file."
    }
    ],
    "out_of_scope_findings": []
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    File-surface addendum to claim 6251425947 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T17:18Z. ⛔ Not a new claim; the claim's branch and every other line stand.

    • Added to File surface: packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts, a new test file only. @objectstack/objectql depends on @objectstack/metadata-protocol, so the loader's own package cannot reach a real engine and driver without a cycle. The existing real-driver seed pins live in packages/runtime/src for the same reason. It is declared to domain:cli (6252660595, 6252666184).
    • Also on the PR: scripts/engine-double-contract.pinned.json, 3 rows written by the gate's own --write for the new unit test's fake, as that gate prescribes.
    • Source: PR fix(metadata-protocol)!: a scoped seed load refuses a natural key a NULL-organization row holds instead of inserting a twin #24372's CONTRACT_REVIEW_TIER record (③.1 and ③.2), and the dev's report 6252574758 (open question 4).

    Generated by Claude Code

  7. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24372 comment 6252822792 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T17:20Z.


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T17:54Z

    Probe Reading
    Review CONTRACT_REVIEW_TIER record 6252805355, PASS, on the narrowing face, at head 7cfdef026d. ACCEPT 6252822792. Not governed (0 of 5 paths).
    Queue It entered the merge queue at 2026-10-18T17:22Z and left it merged at 2026-10-18T17:52Z. No gh-readonly-queue/main/pr-24372-* branch remains.
    origin/main 41c7405364 is on origin/main. It carries PR #24372's 5 files, +673 / −2.
    Card Closed completed through Fixes #24301; pm:dispatched is cleared.

    What main now holds: an organization-scoped seed load (upsert or ignore) refuses a seed record whose natural key only a NULL-organization row holds. The refusal is a named per-row error in the seed result, the operator sees it in the replay's error count and samples, and the row is never adopted. Another organization's rows stay invisible. An unscoped load and a fully stamped database load as before.

    Both legs of #24282 are now on main: leg A as PR #24325 (51ad8e4fb), on the upgrade path, and leg B as this PR, for every scoped seed caller. objectstack-ai/hotcrm#2146 waited on them; a pointer is on #24282. The dev session session_01HsiLAicj3hYCseWnNxYkCj is archived.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p1High: required for production / M2target:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions