Skip to content

fix(plugin-security): the seed ownership claim writes with skipAutomations — no app hooks, flows, approvals or notifications on the first sign-up - #22069

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22067-seed-claim-skip-automations
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22067-seed-claim-skip-automations

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22067
Clause-②: no

What changed

claimSeedOwnership hands 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: "Run reown with { 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.

file change
packages/plugins/plugin-security/src/claim-seed-ownership.ts CLAIM_WRITE_CTX, used by reown only; the header says why, and which hooks still run
packages/plugins/plugin-security/src/claim-seed-ownership.test.ts every update call (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) real ObjectQL over SqlDriver: what the flag reaches, and the per-row hook ceiling
packages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts (new) booted app, first sign-up over REST: no automation in the claim window; audit rows and sharing grants still land
.changeset/22067-seed-claim-skip-automations.md patch for @objectstack/plugin-security, with the line that app hooks no longer fire for the seed ownership claim

No packages/objectql change, 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 debug on an empty database. The seed inserts 132 rows; 20 s after [Seeder] Seed loading complete, the first user signs up with POST /api/v1/auth/sign-up/email (curl time_total). The claim window runs from first user promoted to platform admin to handed 127 seeded record(s) to platform admin in the same request's log.

  • before: this branch with claim-seed-ownership.ts restored to 56bf27affb (blob 78f3850d4a), @objectstack/plugin-security rebuilt; dist/ verified to carry the base write (multi: true, context: SYSTEM_CTX9 } x1, CLAIM_WRITE_CTX x0), then restored by blob and rebuilt.
  • after: this branch, rebuilt.
  • One shared container for every run: absolute seconds are shared-box readings, and the ratios are what carries.
leg sign-up wall time (3 runs) claim window (3 runs)
before 1.540 / 1.670 / 1.675 s 1.113 / 1.200 / 1.145 s
after 0.947 / 0.954 / 1.016 s 0.499 / 0.466 / 0.557 s

Claim-window counts, identical in all three runs of each leg:

in the claim window before after
rows claimed 127 (18 of 22 eligible objects) 127 (18 of 22)
predicate writes (Update operation starting) 44 44
app hook bodies run ([BodyRunner] hook fired) 10 0
record-change flow dispatches (start condition evaluated) 117 0
flows run / approvals opened (approval node suspended run) 2 / 2 0 / 0
emails handed to the transport ([LogTransport] would send email) 0 0
metadata-bound dispatches skipped (Skipping metadata-bound hook (skipAutomations)) 0 416
  • The showcase's flows gate on transitions (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 draft showcase_approver_bindings flow has no condition, so the claim ran it on both seeded showcase_field_zoo rows and opened 2 approval requests, each "resolved to no concrete approver". The showcase wires no email transport, so 0 emails before and after.
  • The 416 skipped dispatches: 35 app-hook dispatches (showcase_normalize_task_title 10, showcase_guard_task_reopen 10, showcase_audit_task_completion 10, showcase_warn_over_budget 5), 127 sys_stamp_audit_update, and 254 sys_stamp_audit_insert on plugin-audit's 127 sys_audit_log and 127 sys_activity inserts.
  • The card's hotcrm reading (17.7.0, a 354-row seed, about 45 s, 1,254 app hooks, 8 flow runs, 2 approvals, 8 emails) is far larger than anything in this repo's examples. The dogfood fixture below carries the hotcrm shape: a condition-free flow that notifies and opens an approval, on seeded rows.

The pins

  • The claim dispatches no automation, measured on a booted app (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, AuditPlugin and sharing. The first user signs up over POST /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's owner_id is the admin. A positive control (the admin's own PATCH of one deal) fires the hook twice and runs the flow once, with its approval.
  • Audit and sharing still run (数据导入:批量 insert 给 Hook 的输入形状与单条不一致(installFlatInput 失效);「运行自动化与触发器」开关是摆设且默认值应为选中 #2922). The same window has one sys_audit_log update row per claimed deal, each recording the new owner and carrying created_at. It also has one sys_record_share rule grant per deal, from a criteria rule (record.owner_id != null, shared with the owner_id field) whose grants only the claim's owner change can earn. Before the sign-up there are 0 grants.
  • What the flag reaches, measured on a real engine (claim-seed-ownership-dispatch.pin.test.ts). A hook bound through bindHooksToEngine (so it carries meta, like an app hook) gets 0 dispatches. A code-registered hook gets one per-row dispatch per claimed row and phase, each with isSystem, skipAutomations and skipTriggers true. skipTriggers is the one field RecordChangeTrigger's handler reads before dispatching a flow. Control: a plain system predicate write over the same rows fires the metadata hook 2 x N.
  • The per-row hook ceiling (mechanism hypothesis 2), measured. With MAX_BULK_PER_ROW_HOOK_ROWS + 500 unowned rows, a { isSystem: true, skipAutomations: true } predicate write is still refused whole (code and limit asserted, 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_ROWS is unchanged, because its derivation is still right.
  • Every caller (mechanism hypothesis 3). Both callers write through claimSeedOwnership: the promotion pass (bootstrap-platform-admin.ts, inside the first sign-up) and the app:seeded settle pass (security-plugin.ts claimSeedOwnershipOnSettle, on every boot including warm boots). The changeset names both.

Ablation (node scripts/ablation-replace.mjs, anchor CLAIM_WRITE_CTX = { isSystem: true, skipAutomations: true }; replaced with CLAIM_WRITE_CTX = { isSystem: true };, landed x1 to x0, blob fa63cffb37 to 7b581f3b26):

  • ablation-dist-preflight found the marker in dist/index.js and dist/index.mjs.
  • The claim unit test went red: expected { isSystem: true } to deeply equal { isSystem: true, …(1) }.
  • The real-engine pin went red twice: a metadata-bound hook fired for the claim: expected [ …(80) ] to deeply equal [], and on the fallback expected [ …(21000) ] to deeply equal [].
  • The dogfood pin went red: appHooks: 8, flowRuns: 4, approvals: 4, notifications: 4 against all-zero.
  • Restore: blob == HEAD and git diff HEAD empty, @objectstack/plugin-security rebuilt, and --absent preflight 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/** and packages/**. Outside test fixtures, packages/** ships none. None reads an owner_id change, so this needs no decision:

  • app-crm. Hook opportunity_stage_probability (crm_opportunity, before insert and update) reads stage. convert-lead is a screen flow, not record-triggered.
  • app-showcase hooks (src/data/hooks/index.ts). showcase_normalize_task_title reads title, showcase_audit_task_completion reads done, showcase_warn_over_budget reads spent/budget, showcase_stamp_inquiry_defaults reads status/source on insert, and showcase_guard_task_reopen reads done.
  • app-showcase record-triggered flows (src/automation/flows/index.ts unless noted). Their start conditions read status (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) and title (showcase_dynamic_approval, dynamic-approval.flow.ts). Three are insert-only (showcase_declarative_connector_ping, _mcp_connector_echo, _task_follow_up). The draft showcase_approver_bindings (approver-bindings.flow.ts) has no condition. Several notify {record.owner}, a business field, not owner_id.
  • app-todo. Hook task_logic and flow task_completion read status.
  • Fixture only. packages/verify/src/handle.fixture.ts hooks on hnd_deal read 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 on aa21b6c3f9, exit code captured before any pipe, every one exit 0. --ran with 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 unwrap satisfies.
    • 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, after pnpm --filter '@objectstack/plugin-security^...' build:
    • typecheck passes: tsc plus check:test-typecheck: OK … 0 error(s);
    • vitest run gives 169 files, 3640 passed, 45 skipped.
  • Dogfood tests that drive the seed claim. Every bootStack boot 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-probe and admin-platform-admin-standing, 6 files, 46 passed.
    • --project shared-showcase: showcase-private-owd and showcase-public-read-owd, 2 files, 8 passed.
    • @objectstack/dogfood typecheck passes.
  • The rest of the dogfood suite is left to CI's Dogfood Regression Gate.
  • eslint --no-inline-config --format json on the 4 changed .ts files gives 4 files, 0 errors, 0 warnings. Each file matches the config (--print-config exits 0). This repo's config enables no type-aware linting (eslint.config.mjs near line 327: no parserOptions.project), so this diff cannot move any untouched file's verdict. The repo-wide pnpm lint is CI's.

Acceptance notes

  • ObjectQL's sys_stamp_audit_insert / sys_stamp_audit_update are skipped under skipAutomations. They are bound through ql.bindHooks (packages/objectql/src/plugin.ts, packageId sys:audit), so they carry meta. The A/B log shows them among the 416 skipped dispatches.
    • For this write that changes nothing. The claim has no user to stamp into updated_by, and every driver stamps created_at/updated_at itself. Measured on the dogfood fixture: the claimed rows' updated_at advances (…:55.456Z to …:57.042Z), updated_by stays null as before, and the claim's audit rows carry created_at.
    • It does contradict the ExecutionContext.skipAutomations description, which lists audit among the hooks that still run. For a data import with "run automations" unchecked, created_by/updated_by stamping would be skipped. That is NOT MEASURED. Noted, not filed. Carrier: none.
  • The multi-tenant twin, 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.
  • The draft showcase_approver_bindings says "Draft on purpose", but draft flows fire their triggers (service-automation logs that at boot). At 56bf27affb the 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 a showcase_field_zoo row still would; that is NOT MEASURED. Carrier: none.

References only: #14530, #14719, #21486, objectstack-ai/hotcrm#2008.


Generated by Claude Code

claude added 7 commits October 7, 2026 10:39
…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>
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 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
  • 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 — 16 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 879bd38c5b387fa482ac6097b459fee5bd3a8b76 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d32948341fd93e14a7ab5ff94b40401df7228ea9 — the merge of head aa21b6c3f9cc26c85c7d6df9c0d814bd1dcec432 into base 879bd38c5b387fa482ac6097b459fee5bd3a8b76, 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 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Projects

None yet

2 participants