Repository navigation
fix(plugin-security,organizations): seed-ownership claims re-own by row when a stored expression reads owner_id - #25724
Conversation
…ow when a stored expression reads owner_id Claude-Session: https://claude.ai/code/session_01T8djanm8XBb36LFwcQEXJP Co-authored-by: Claude <noreply@anthropic.com>
…xpression reads owner_id Claude-Session: https://claude.ai/code/session_01T8djanm8XBb36LFwcQEXJP Co-authored-by: Claude <noreply@anthropic.com>
…im's member reader Claude-Session: https://claude.ai/code/session_01T8djanm8XBb36LFwcQEXJP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 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 20fac24059e39f77cfdf3973f6a945168838e891 && git checkout 20fac24059e39f77cfdf3973f6a945168838e891
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6ba1343ba41cb173baadfc36e6069123d3f2cfd 1e608716dc888a7fe6f50191949d227ebae09469 && git checkout -B drift-repro f6ba1343ba41cb173baadfc36e6069123d3f2cfd && git merge --no-ff 1e608716dc888a7fe6f50191949d227ebae09469
node scripts/docs-audit/affected-docs.mjs --json f6ba1343ba41cb173baadfc36e6069123d3f2cfd
|
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | draft, base main, Fixes #25668, Clause-②: no and Metadata-protocol: none line-initial in the body and the changeset. Not governed (check-governed-merges --pr 25724), 812 changed lines. |
| Ruling held | triage 6385814124: the fix is at the two claim sites only; nothing under packages/objectql changes, so the engine's refusal of a mixed-value predicate write stays. |
| Seat's read of the diff | both claims pick the write per object through storedExpressionReadsOwner: a non-formula field with an expression whose reads (via @objectstack/formula's analyzeRelationshipTraversals) reach owner_id directly, through a hop, or transitively through another stored field; an unreadable expression counts as a reader (fail toward the slower, correct path). Such objects get one write per row that keeps the claim's own owner term (owner_id: null or the seed identity, plus the organization) and adds id, so a row owned in the meantime is not taken. Every other object keeps the existing predicate write and paging. A refused row is reported; the rest are still claimed. |
| Surface outside the claim | organizations/package.json + pnpm-lock.yaml (new @objectstack/formula dependency) and organizations/vitest.config.ts (the source alias check:test-source-alias requires). Declared; all three follow from the fix. |
| Changeset sentence check | Before / Now / Unchanged / the new dependency / What to do: each matches a hunk read above; both packages patch. |
| Tests | two real-engine pin files, 4/4 each, covering direct and one-hop reads with rows that compute different values; restoring the bulk write turns 3 of 4 red in each with the engine's "update those rows by id" refusal, restored to HEAD. |
| Gates | dev union 84/84 exit 0 at this head. CI at this act: 13 success, 6 opt-in skipped, 20 in progress, 0 failed. mcp_calls: 2 reads, 0 writes. |
| Acceptance note | expressionReads and storedExpressionReadsOwner are written twice, once per plugin, identical by design (the two claims are twins and organizations does not depend on plugin-security). Not a defect today; a later edit to one must be mirrored. Noted, not filed. |
| Landing | ready + auto-merge through the relay once every check on this head is green or an expected skip. Downstream: hotcrm#2242 unlocks once a published release carries this. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #25668
Clause-②: no
Metadata-protocol: none
What changed
Both seed-ownership claims re-owned seeded rows with one
multi: truepredicate update. A stored computed field (a non-formula field with anexpression) that readsowner_id, directly or one hop through the lookup, computes a different value on each row once the owner is set. The engine refuses such a predicate update ("update those rows by id"), so every seeded row of that object stayed ownerless. Triage6385814124keeps that refusal and routes the fix to the two claim sites. This PR does that.packages/plugins/plugin-security/src/claim-seed-ownership.ts: a newstoredExpressionReadsOwner(schema)decides, per object, whether a stored expression readsowner_id. It counts a direct read, a hop throughowner_id, and a read through another stored field that does. An expression whose reads cannot be named counts as a reader. Reads come fromanalyzeRelationshipTraversals(@objectstack/formula), the member reader the engine's stored-expression planning uses. Such an object goes toclaimPredicateByRow:usr_systemarm reads ids a page at a time (CLAIM_PAGE_ROWS, the bound the paged fallback already reads by) and writes them. It stops when a read is empty, or when a page re-owns nothing.id,multi: true. The engine evaluates that as a predicate over one row and computes the stored value from that row. A row someone owned in the meantime is not taken.packages/plugins/organizations/src/claim-org-seed-ownership.ts: the same check (a twin, as the two files already twin their helpers) andclaimChunkByRow. It writes one compare-and-set per seeded id of each chunk:{ organization_id, owner_id: null, id }.claimSeedOwnership failed for …/claimOrgSeedOwnership failed for …), with the refused count, up to 20 ids and the first refusal's message. The object's other rows are still claimed.@objectstack/organizationsgains the@objectstack/formuladependency (package.json,pnpm-lock.yaml). Itsvitest.config.tsgains the matching source alias, whichpnpm check:test-source-aliasasked for.File surface: the claim's two files, their tests, one changeset, plus the three organizations manifest/config lines above. Nothing under
packages/objectql/**.Evidence
Head
1e608716. Every number below was read on this branch.plugin-security/src/claim-seed-ownership-stored-expression.pin.test.ts, onSqlDriver(better-sqlite3).organizations/src/claim-org-seed-ownership-stored-expression.pin.test.ts, onSqliteWasmDriver.display_titleexpression), each with four seeded rows that compute different values. The plugin-security pin adds two seed-identity rows. After the claim every such row is owned and its storeddisplay_titleis its own expression over its own row. The guest row the seed never reported, another organization's row and a human-owned row are untouched. A control object whose stored expression reads no owner keeps the bulk path: plugin-security makes 2 predicate writes and organizations 1. A row refused by a code-registered hook stays unowned and reported while the rest are claimed.Tests 4 passed (4)in each file.scripts/ablation-replace.mjs, run, restore; the tool verified the blob back to HEAD andgit diff HEADempty):const claim = claimPredicate;givesTests 3 failed | 1 passed (4). The failures carry the engine's own refusal:'display_title' on 'probe_forecast_direct' is computed from its expression on every write, and this update changes its inputs on rows where it then computes different values, … update those rows by id. That is the card's measured symptom.const claim = claimChunk;givesTests 3 failed | 1 passed (4), with the same refusal. This measures the organizations claim, which the card had only read.@objectstack/plugin-securityTest Files 223 passed,Tests 4632 passed | 45 skipped.@objectstack/organizationsTest Files 17 passed,Tests 243 passed(re-run at1e608716).typecheckexit 0 on both.eslint --no-inline-configover both packages exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 84 families on this diff, and all 84 exit 0 at1e608716. Reconciled:dispatch-gates --ranreads "84 derived famil(ies) accounted for — 84 run, 0 NOT-MEASURED (a DERIVED zero — all 84 recorded an exit code and none of them is 3)". The first round, at5aa93aa9, found one real red,check:test-source-alias(the new@objectstack/formulaimport had no source alias in organizations), fixed in1e608716. It also had three prerequisite exits (3):check:dual-build-cjs-loadsandcheck:i18n(some packages had nodist/) andcheck-plugin-teardown-shape --self-test(shallow clone; its pinned fixture commit fetched). All three exit 0 on the final run. Repo-wide families (pnpm lintand the CI roster) are CI's.Acceptance notes
owner_id. The NULL arm is bounded by the seed's own report (chunks ofCLAIM_PAGE_ROWS). The seed-identity arm is bounded by pages ofCLAIM_PAGE_ROWSandMAX_CLAIM_PAGES. Not measured at seed sizes beyond the pins.owner_idwith the platform's one member reader. A stored expression that reachesowner_idthrough a spelling that reader does not name would still get the predicate write and the existing warning. No such spelling is known. Noted, not filed.storedExpressionReadsOwner/expressionReadsexist in both claim files, asaffectedRowCountandhasFieldalready do. The two packages share no runtime dependency that could host them. Noted, carrier: none.display_titlebooting with 0 ownerless forecasts, needs a release that carries this. It is therepo:hotcrmseat's reading on hotcrm#2242.Generated by Claude Code