Skip to content

fix(plugin-security,organizations): seed-ownership claims re-own by row when a stored expression reads owner_id - #25724

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-8nk0pr
Oct 25, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-8nk0pr

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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: true predicate update. A stored computed field (a non-formula field with an expression) that reads owner_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. Triage 6385814124 keeps 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 new storedExpressionReadsOwner(schema) decides, per object, whether a stored expression reads owner_id. It counts a direct read, a hop through owner_id, and a read through another stored field that does. An expression whose reads cannot be named counts as a reader. Reads come from analyzeRelationshipTraversals (@objectstack/formula), the member reader the engine's stored-expression planning uses. Such an object goes to claimPredicateByRow:
    • The NULL arm writes each seeded id it already names, with no read.
    • The usr_system arm 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.
    • Each write is a compare-and-set: the arm's owner term plus the row's 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.
    • Every other object keeps the predicate write and its paged fallback, unchanged.
  • packages/plugins/organizations/src/claim-org-seed-ownership.ts: the same check (a twin, as the two files already twin their helpers) and claimChunkByRow. It writes one compare-and-set per seeded id of each chunk: { organization_id, owner_id: null, id }.
  • A row whose own write is refused stays unowned. It is reported once per batch with the claim's existing warning (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/organizations gains the @objectstack/formula dependency (package.json, pnpm-lock.yaml). Its vitest.config.ts gains the matching source alias, which pnpm check:test-source-alias asked 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.

  • New pins, real engine:
    • plugin-security/src/claim-seed-ownership-stored-expression.pin.test.ts, on SqlDriver (better-sqlite3).
    • organizations/src/claim-org-seed-ownership-stored-expression.pin.test.ts, on SqliteWasmDriver.
    • Each pins: the direct and the one-hop shape (the hop is HotCRM's display_title expression), 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 stored display_title is 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.
    • Result: Tests 4 passed (4) in each file.
  • Ablation (restore the bulk write via scripts/ablation-replace.mjs, run, restore; the tool verified the blob back to HEAD and git diff HEAD empty):
    • plugin-security: const claim = claimPredicate; gives Tests 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.
    • organizations: const claim = claimChunk; gives Tests 3 failed | 1 passed (4), with the same refusal. This measures the organizations claim, which the card had only read.
  • Package suites: @objectstack/plugin-security Test Files 223 passed, Tests 4632 passed | 45 skipped. @objectstack/organizations Test Files 17 passed, Tests 243 passed (re-run at 1e608716). typecheck exit 0 on both. eslint --no-inline-config over both packages exit 0.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 84 families on this diff, and all 84 exit 0 at 1e608716. Reconciled: dispatch-gates --ran reads "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, at 5aa93aa9, found one real red, check:test-source-alias (the new @objectstack/formula import had no source alias in organizations), fixed in 1e608716. It also had three prerequisite exits (3): check:dual-build-cjs-loads and check:i18n (some packages had no dist/) and check-plugin-teardown-shape --self-test (shallow clone; its pinned fixture commit fetched). All three exit 0 on the final run. Repo-wide families (pnpm lint and the CI roster) are CI's.

Acceptance notes

  • Row-by-row cost. It is a full engine write per row, paid only on objects whose stored expressions read owner_id. The NULL arm is bounded by the seed's own report (chunks of CLAIM_PAGE_ROWS). The seed-identity arm is bounded by pages of CLAIM_PAGE_ROWS and MAX_CLAIM_PAGES. Not measured at seed sizes beyond the pins.
  • Spellings. The routing reads owner_id with the platform's one member reader. A stored expression that reaches owner_id through 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.
  • Twin helpers. storedExpressionReadsOwner / expressionReads exist in both claim files, as affectedRowCount and hasField already do. The two packages share no runtime dependency that could host them. Noted, carrier: none.
  • Not measured here. The card's second "Done when" line, HotCRM's patched display_title booting with 0 ownerless forecasts, needs a release that carries this. It is the repo:hotcrm seat's reading on hotcrm#2242.
  • The card's "Context, not asked here" item (a stored title reading a lookup goes stale when the lookup target is renamed) is untouched.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/organizations, @objectstack/plugin-security, touching 8 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/organizations/package.json, packages/plugins/organizations/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f6ba1343ba41cb173baadfc36e6069123d3f2cfd.

⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/plugins/organizations/package.json, packages/plugins/organizations/vitest.config.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 23 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 f6ba1343ba41cb173baadfc36e6069123d3f2cfd → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 20fac24059e39f77cfdf3973f6a945168838e891 — the merge of head 1e608716dc888a7fe6f50191949d227ebae09469 into base f6ba1343ba41cb173baadfc36e6069123d3f2cfd, 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 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

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

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-25T00:22Z

Head 1e608716dc, report 6391350531 on #25668, claim 6390704686.

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 25, 2026 00:48
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 25, 2026 00:48
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 43edf90 Oct 25, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-8nk0pr branch October 25, 2026 01:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants