Repository navigation
fix(plugin-security): the seed ownership claim writes with skipAutomations — no app hooks, flows, approvals or notifications on the first sign-up - #22069
Conversation
…tions
The claim's reown write re-owns seeded rows to the platform admin. It is
attribution that completes the seed, not a user event, so it now runs under
{ isSystem: true, skipAutomations: true }: no metadata-bound hook fires and
no record-change flow dispatches, while code-registered hooks (audit, the
sharing projection) still run.
Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu
Co-authored-by: Claude <noreply@anthropic.com>
…and a booted app Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…ne assertion Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…audit rows Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…t-out ObjectQL's sys_stamp_audit builtins are bound through the hook binder, so they carry metadata and skipAutomations skips them too; for the claim that changes nothing, and the comment now says so rather than listing them among the hooks that run. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
The tenant-audit census reads isSystem off a context constant statically and does not unwrap a satisfies expression, so the satisfies-wrapped spelling moved this write from "decidably elevated" to "elevation undecidable". Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 16 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 d32948341fd93e14a7ab5ff94b40401df7228ea9 && git checkout d32948341fd93e14a7ab5ff94b40401df7228ea9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 879bd38c5b387fa482ac6097b459fee5bd3a8b76 aa21b6c3f9cc26c85c7d6df9c0d814bd1dcec432 && git checkout -B drift-repro 879bd38c5b387fa482ac6097b459fee5bd3a8b76 && git merge --no-ff aa21b6c3f9cc26c85c7d6df9c0d814bd1dcec432
node scripts/docs-audit/affected-docs.mjs --json 879bd38c5b387fa482ac6097b459fee5bd3a8b76 |
Fixes #22067
Clause-②: no
What changed
claimSeedOwnershiphands every seeded row to the first platform admin. Its one write,reown(a predicate write per unowned shape, paged only when the engine refuses it for the per-row hook ceiling), now runs with{ isSystem: true, skipAutomations: true }instead of a bare{ isSystem: true }. This is triage's ruling on the card (comment 6035454999), verbatim: "Runreownwith{ isSystem: true, skipAutomations: true }. This is the seed's own principle (engine.ts:5413: seed loads end-state data, not user events), carried to the attribution write that completes the seed." The page read (readPage) is unchanged.packages/plugins/plugin-security/src/claim-seed-ownership.tsCLAIM_WRITE_CTX, used byreownonly; the header says why, and which hooks still runpackages/plugins/plugin-security/src/claim-seed-ownership.test.tsupdatecall (refused whole-set attempts, fallback pages, the closing whole-set write) carries exactly{ isSystem: true, skipAutomations: true }packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts(new)ObjectQLoverSqlDriver: what the flag reaches, and the per-row hook ceilingpackages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts(new).changeset/22067-seed-claim-skip-automations.mdpatchfor@objectstack/plugin-security, with the line that app hooks no longer fire for the seed ownership claimNo
packages/objectqlchange, the claim stays inside the request, and no narrower flag was added.Latency A/B, built from source
Example:
examples/app-showcase, the in-repo example that carries metadata hooks and record-change flows on seeded objects. Each run:OS_SEED_ADMIN=0 objectstack dev --fresh --no-seed-admin --log-level debugon an empty database. The seed inserts 132 rows; 20 s after[Seeder] Seed loading complete, the first user signs up withPOST /api/v1/auth/sign-up/email(curltime_total). The claim window runs fromfirst user promoted to platform admintohanded 127 seeded record(s) to platform adminin the same request's log.claim-seed-ownership.tsrestored to56bf27affb(blob78f3850d4a),@objectstack/plugin-securityrebuilt;dist/verified to carry the base write (multi: true, context: SYSTEM_CTX9 }x1,CLAIM_WRITE_CTXx0), then restored by blob and rebuilt.Claim-window counts, identical in all three runs of each leg:
Update operation starting)[BodyRunner] hook fired)approval node suspended run)[LogTransport] would send email)Skipping metadata-bound hook (skipAutomations))status == "done" && previous.status != "done"and the like), so an owner-only change evaluates 117 start conditions and runs none of them, with one exception. The draftshowcase_approver_bindingsflow has no condition, so the claim ran it on both seededshowcase_field_zoorows and opened 2 approval requests, each "resolved to no concrete approver". The showcase wires no email transport, so 0 emails before and after.showcase_normalize_task_title10,showcase_guard_task_reopen10,showcase_audit_task_completion10,showcase_warn_over_budget5), 127sys_stamp_audit_update, and 254sys_stamp_audit_inserton plugin-audit's 127sys_audit_logand 127sys_activityinserts.The pins
seed-ownership-claim-dispatch.dogfood.test.ts). Four seeded deals carry a condition-free metadata hook and a condition-free record-change flow (notify the owner, then an approval). The real chain is mounted: automation,RecordChangeTriggerPlugin, approvals, messaging,AuditPluginand sharing. The first user signs up overPOST /api/v1/auth/sign-up/email(dev admin seed off). The claim window shows 0 metadata-hook dispatches, 0 flow runs, 0 approvals and 0 notifications, and every deal'sowner_idis the admin. A positive control (the admin's ownPATCHof one deal) fires the hook twice and runs the flow once, with its approval.sys_audit_logupdate row per claimed deal, each recording the new owner and carryingcreated_at. It also has onesys_record_sharerule grant per deal, from a criteria rule (record.owner_id != null, shared with theowner_idfield) whose grants only the claim's owner change can earn. Before the sign-up there are 0 grants.claim-seed-ownership-dispatch.pin.test.ts). A hook bound throughbindHooksToEngine(so it carriesmeta, like an app hook) gets 0 dispatches. A code-registered hook gets one per-row dispatch per claimed row and phase, each withisSystem,skipAutomationsandskipTriggerstrue.skipTriggersis the one fieldRecordChangeTrigger's handler reads before dispatching a flow. Control: a plain system predicate write over the same rows fires the metadata hook 2 x N.MAX_BULK_PER_ROW_HOOK_ROWS + 500unowned rows, a{ isSystem: true, skipAutomations: true }predicate write is still refused whole (codeandlimitasserted, nothing written, no hook run). The engine counts matched rows against the ceiling whenever any hook covers the object and does not consult the flag. The claim then pages to every row (10 500 of 10 500), with 0 metadata-hook dispatches.CLAIM_PAGE_ROWSis unchanged, because its derivation is still right.claimSeedOwnership: the promotion pass (bootstrap-platform-admin.ts, inside the first sign-up) and theapp:seededsettle pass (security-plugin.tsclaimSeedOwnershipOnSettle, on every boot including warm boots). The changeset names both.Ablation (
node scripts/ablation-replace.mjs, anchorCLAIM_WRITE_CTX = { isSystem: true, skipAutomations: true };replaced withCLAIM_WRITE_CTX = { isSystem: true };, landed x1 to x0, blobfa63cffb37to7b581f3b26):ablation-dist-preflightfound the marker indist/index.jsanddist/index.mjs.expected { isSystem: true } to deeply equal { isSystem: true, …(1) }.a metadata-bound hook fired for the claim: expected [ …(80) ] to deeply equal [], and on the fallbackexpected [ …(21000) ] to deeply equal [].appHooks: 8, flowRuns: 4, approvals: 4, notifications: 4against all-zero.git diff HEADempty,@objectstack/plugin-securityrebuilt, and--absentpreflight shows the marker gone from all 6 built files with a clean tree.Owner-change readers (first-party, in tree)
I enumerated every metadata-bound hook and record-triggered flow in
examples/**andpackages/**. Outside test fixtures,packages/**ships none. None reads anowner_idchange, so this needs no decision:opportunity_stage_probability(crm_opportunity, before insert and update) readsstage.convert-leadis a screen flow, not record-triggered.src/data/hooks/index.ts).showcase_normalize_task_titlereadstitle,showcase_audit_task_completionreadsdone,showcase_warn_over_budgetreadsspent/budget,showcase_stamp_inquiry_defaultsreadsstatus/sourceon insert, andshowcase_guard_task_reopenreadsdone.src/automation/flows/index.tsunless noted). Their start conditions readstatus(showcase_task_completed,_task_completed_slack,_task_completed_rest_ping,_task_done_notify_owner,_fan_out_notify,_resilient_sync,_project_closure,_invoice_signoff,_expense_signoff,_committee_quorum),assignee(showcase_task_assigned_notify),budget(showcase_budget_approval),health(showcase_project_escalation),priority(showcase_urgent_task_alert) andtitle(showcase_dynamic_approval,dynamic-approval.flow.ts). Three are insert-only (showcase_declarative_connector_ping,_mcp_connector_echo,_task_follow_up). The draftshowcase_approver_bindings(approver-bindings.flow.ts) has no condition. Several notify{record.owner}, a business field, notowner_id.task_logicand flowtask_completionreadstatus.packages/verify/src/handle.fixture.tshooks onhnd_dealread no owner field.Gates and tests (head
aa21b6c3f9)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived on this change with no paths, gives 69 families: the 57 at dispatch plus 12 for the changeset and the new files. All 69 were run onaa21b6c3f9, exit code captured before any pipe, every oneexit 0.--ranwith the exit codes: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero …)".check-tenant-audit-census: "OK -- 233 write call sites certified". The write context is a plain literal on purpose. Spelled{ … } as const satisfies ExecutionContext, it moved this write from "decidably elevated" to "elevation undecidable" in the census (122/103 against the page's 123/102), because the census does not unwrapsatisfies.check:engine-double-contract: "OK — 980 pinned".check:nul-bytes: "OK (scanned 10064 text file(s) …)".check:dual-build-cjs-loads: "106 published require entry point(s) across 66 package(s) load", after building the 8 packages its prerequisite named.@objectstack/plugin-security, afterpnpm --filter '@objectstack/plugin-security^...' build:typecheckpasses:tscpluscheck:test-typecheck: OK … 0 error(s);vitest rungives 169 files, 3640 passed, 45 skipped.bootStackboot runs it through the dev admin seed; the new one runs it through REST.--project isolated:seed-ownership-claim-dispatch,owner-anchor-and-bulk-writes,authored-row-write-scope,predicate-write-unreadable-not-matched,bulk-widener-probeandadmin-platform-admin-standing, 6 files, 46 passed.--project shared-showcase:showcase-private-owdandshowcase-public-read-owd, 2 files, 8 passed.@objectstack/dogfoodtypecheckpasses.eslint --no-inline-config --format jsonon the 4 changed.tsfiles gives 4 files, 0 errors, 0 warnings. Each file matches the config (--print-configexits 0). This repo's config enables no type-aware linting (eslint.config.mjsnear line 327: noparserOptions.project), so this diff cannot move any untouched file's verdict. The repo-widepnpm lintis CI's.Acceptance notes
sys_stamp_audit_insert/sys_stamp_audit_updateare skipped underskipAutomations. They are bound throughql.bindHooks(packages/objectql/src/plugin.ts, packageIdsys:audit), so they carrymeta. The A/B log shows them among the 416 skipped dispatches.updated_by, and every driver stampscreated_at/updated_atitself. Measured on the dogfood fixture: the claimed rows'updated_atadvances (…:55.456Zto…:57.042Z),updated_bystays null as before, and the claim's audit rows carrycreated_at.ExecutionContext.skipAutomationsdescription, which lists audit among the hooks that still run. For a data import with "run automations" unchecked,created_by/updated_bystamping would be skipped. That is NOT MEASURED. Noted, not filed. Carrier: none.claimOrgSeedOwnership(packages/plugins/organizations/src/claim-org-seed-ownership.ts), re-owns seed rows with single-id writes under a bare{ isSystem: true }, so it has the same dispatch shape. It is not touched here. Its reach is NOT MEASURED (it needs a multi-tenant boot). Carrier: none.showcase_approver_bindingssays "Draft on purpose", but draft flows fire their triggers (service-automation logs that at boot). At56bf27affbthe first sign-up's claim ran it and opened 2 approval requests with no concrete approver. On this branch the claim no longer reaches it. A user edit of ashowcase_field_zoorow still would; that is NOT MEASURED. Carrier: none.References only: #14530, #14719, #21486, objectstack-ai/hotcrm#2008.
Generated by Claude Code