Repository navigation
[finding] The derived capability seeder rewrites a platform-marked row INSIDE an organization and still leaves the platform bucket unseeded — silently, on the UPDATE branch #8536 did not scope #8751
Description
Activity
Triage — finding first-touch grading: PROMOTE ⇒
pm:queue,domain:metadata, typeBug.findingremoved.Why
Bugand not an observationThe card presents itself as observation-class and defers severity to triage, so here is the call and its reasoning. This is not a new judgement about what the seeder should do — the invariant was already decided and is already enforced on the neighbouring branch:
- The back-compat capability derivation refreshes label/description on ANY existing sys_capability row, including admin-authored ones — the comment above it says the opposite #5876 established that the derived half must never rewrite an author's copy with
humanize(name).managed_by: 'platform'was the test chosen to enforce it. bootstrapSystemCapabilitiesreconciles an arbitrary row when a curated capability name exists in more than one organization —find(..., limit: 1)has no ORDER BY, so the platform's own row can be left unseeded #8470 fixed exactly this asymmetry for the CURATED half, and its predicate is the conjunctionmanaged_by: 'platform'ANDorganization_id: null, precisely becausemanaged_byalone "would not carry that guarantee".- fix(platform-objects,plugin-security,driver-sql): scope sys_user_preference and sys_capability uniqueness per organization (#8323) #8461 (ADR-0120 D1) made
nameunique per organization rather than installation-wide, which is what turned the single-condition test from sufficient into insufficient.
So the derived half is running a guard that the repo has already ruled inadequate and already replaced next door. Accept set unchanged, no new behaviour proposed,
declared = enforcedrestored ⇒ Bug, and a small one: the fix shape is "make the derived guard the same conjunction the curated guard already uses".The measured run is the second reason it is not merely dormant — it shows two end states in one pass, and the second is the more serious: an organization's row has its
label/descriptionoverwritten with a generated placeholder, which is the precise harm #5876 exists to prevent. Every counter reads0and nothing is logged, so it is invisible in the boot summary and in the diagnostic #8749 is adding.Deliverable #1 is the reachability measurement the card declined to guess at
The filing is explicit that reachability of a platform-marked row inside an organization is unmeasured, and names seed data and legacy imports as the candidate sources. Settle that first:
- If a shipped install can produce one ⇒ this is a live defect; fix the guard and pin both branches.
- If it cannot ⇒ it is a dormant asymmetry, and the fix is still worth landing (it is a one-predicate change that removes a trap), but the card should say so plainly rather than inherit an unearned severity.
⛔ Do not re-derive the "no runtime path reads a
sys_capabilityrow" claim from scratch — the filing carries it across from #8536 and says so. Cite it, do not re-measure it, unless the guard fix turns out to depend on it.⛔ Hard serial — read before claiming
Blocked in practice by PR #8749 (#8536,
pm:dispatched, in flight): same file,packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts, adjacent branches of the same function. #8536 scopes the branch where the guard declines (addingunseededDerived+ a warning); this card is the branch where the guard admits. Landing them in parallel is the shape where each side is individually green and the merged result is wrong.Not labelled
pm:blocked— the dependency is file-level serialization, not a semantic prerequisite, and #8536 does not change this card's premise. Claim it after #8749 merges, and re-verify the guard's text on the merged ref before writing: #8749 is editing lines immediately adjacent to the predicate this card changes.
Generated by Claude Code
- The back-compat capability derivation refreshes label/description on ANY existing sys_capability row, including admin-authored ones — the comment above it says the opposite #5876 established that the derived half must never rewrite an author's copy with
Claim: PM loop round 4
Session:session_01XeQRiAa7vYRVX5Fog7Zby8
Branch:claude/issue-8751-derived-seeder-org-scoped-guard
Worktree:objectstack-issue-8751
Domain:domain:metadata
File surface:packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts(the derived half's lookup + ownership guard) and its sibling test file (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus
Serial constraints cleared: re-read at dispatch — 5 open PRs. PR #8758 is open on the same package (packages/plugins/plugin-security) but touchessecurity-plugin.tsonly, a different file; measured from its file list, not assumed. #8536 / PR #8749 — the card this one was split out of, same file — is merged (8656d67), so this starts from a tree that already hasunseededDerived. No other in-flight card touches this file.Routing note for triage, not a relabel: this card lands in
packages/plugins/plugin-security, which the domain table assigns todomain:identity, yet it carriesdomain:metadata. I am taking it because the lane already landed its parent #8536 on the same file and splitting the pair across two seats now would be worse than the mislabel. Flagging rather than fixing, per the execution seat's discipline — the routing call is triage's.
Generated by Claude Code
Container restart — dev re-dispatched, claim still live. ⛔ Not a stale claim.
The dev agent on this card was killed by a container restart at ~20:35Z. Recording it so the gap between the claim and the branch is not misread by another seat.
Work survived on disk and is being resumed: worktree
objectstack-issue-8751, branchclaude/issue-8751-derived-seeder-org-scoped-guard, one commitd87cac129(bootstrap-system-capabilities.ts+145, its test +227, and a changeset), clean tree, nothing pushed.The resumed dev is instructed to treat that commit as a draft. In particular I cannot tell from outside whether the predecessor ever answered this card's one genuinely open question — whether a platform-marked row inside an organization is actually reachable on a shipped install, which decides live defect vs dormant asymmetry. That measurement is required before the PR, and the changeset must not claim a live defect if one could not be reached.
Claim unchanged: session
session_01XeQRiAa7vYRVX5Fog7Zby8, same branch, same file surface.
Generated by Claude Code
{ "issue": 8751, "status": "done", "branch": "claude/issue-8751-derived-seeder-org-scoped-guard", "pr": "https://github.com/objectstack-ai/objectstack/pull/8766", "premise_still_valid": true, "summary": "RESUMED after a container restart killed the previous agent; its commit d87cac129 survived on disk and I treated it as a draft, re-verifying rather than assuming. The derived half's ownership test is now the #8470 conjunction (managed_by:'platform' AND organization_id == null), so a platform-STAMPED row inside an organization is no longer admitted and rewritten with the humanized placeholder; the misplaced stamp gets its own platformStampedInOrg counter and a matching warning arm, and #8749's unseededDerived is left exactly as shipped. Deliverable #1 (reachability) is ANSWERED and the answer is a DORMANT asymmetry with a LIVE route, not a live defect: nothing shipped in this repo produces such a row, but the route needs no unsupported step and its load-bearing link is now measured against the real SeedLoaderService rather than traced. I found the previous agent's changeset and file header DID overstate this on two counts and corrected both in commit 380e87d78 — they called a static trace 'measured', and asserted the row appears 'on every new organization of a multi-tenant install', a provisioning claim this repository cannot see. Merged origin/main once (clean, no regen deferral); the sql-driver/mcp 'deletions' were the behind-main artifact the dispatch predicted and disappeared. Stayed off security-plugin.ts (PR #8758's file) — the fix never needed it.", "tests": "All at final commit 380e87d78 (quoted from the run itself). Reachability MEASUREMENT (the card's deliverable #1), real SeedLoaderService, harness shaped like objectql's seed-loader-org-stamp.test.ts: leg A, organizationId pinned => inserted {object:'sys_capability', record:{managed_by:'platform', organization_id:'org_msbubm8g3j35rgx0'}}; leg B control, none pinned => same row inserted with NO organization_id — so the sys_/cloud_/ai_ prefix exemption is otherwise intact and the stamp is the pinning's doing, not a fixture artifact. Harness was scratch, exercised in-tree and deleted (tree verified clean before commit). REVERSE VERIFICATION with the direction predicted BEFORE running: inverting the guard to the pre-change single-condition test => predicted exactly 4 red (the two overwrite/silence pins, the message pin, and the ADVERSE ordering leg only) with both positive controls, the benign leg, the not-backfilled pin, the absent-column case and the whole #5876/#8470/#8536 suites green. Observed: 'Tests 4 failed | 36 passed (40)', failures at lines 783, 796, 828, 906 — the prediction exactly, no collateral. Fix restored via git checkout HEAD -- (fix was committed first; no stash anywhere). SUITES: pnpm --filter @objectstack/plugin-security test => 'Test Files 61 passed (61) / Tests 1177 passed (1177)'; typecheck => clean; pnpm --filter @objectstack/objectql test => 'Test Files 207 passed (207) / Tests 3631 passed (3631)'. GATES, re-derived against actual changed paths via scripts/pm/dispatch-gates.mjs (three-dot origin/main...HEAD — the two-dot form falsely picked up 3 files from commits that landed on main after my merge): check:nul-bytes, check:changeset-gate-self-tests, check:objectui-changeset, check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:engine-double-contract, check:type-check-coverage, check:query-options-erasure, check:i18n — all EXIT=0. check:i18n and check:query-options-erasure came from the re-derivation, not the dispatch list. TEST-LAYER TYPE COVERAGE, worth flagging: plugin-security's tsconfig excludes **/*.test.ts, so the green package typecheck does NOT cover the test file I edited. Measured it separately with the exclusion lifted and the closure built: 11 raw tsc errors, exactly the frozen TEST_DEBT ledger number for this package, and ZERO of them in bootstrap-system-capabilities.test.ts (all 11 are in explain-engine/rls-pushdown-limits/permission-set-projection/default-permission-sets/authz-matrix-gate/audience-anchor-set-claims) — so the ratchet is not moved. Did NOT run check:type-check-debt --re-measure: it refuses without the FULL workspace closure built and would otherwise silently measure a different world; the per-package measurement above is the honest substitute, and CI runs the real one.", "open_questions": [], "out_of_scope_findings": [] }Notes for the PM, outside the JSON:
- The one substantive change I made to the inherited commit is the reachability text in
bootstrap-system-capabilities.ts's header and in.changeset/plump-crabs-sneeze.md. The engineering (guard predicate, counter, warning arm, test suite) I reviewed and agreed with, and left alone. - Both directions were already pinned in the inherited suite, and I confirmed it rather than taking it on trust: the bucket is asserted ABSENT, not seeded ([Decision] When a curated capability name is already held in the platform bucket, should the seeder keep declining — or adopt, backfill, or refuse the collision at authoring time? #8552's posture), and the org-held row is pinned against rewrite. The reverse verification above is what proves the pins are not vacuous.
- Nothing was filed as an out-of-scope finding. The tsconfig test-exclusion I hit is pre-existing, repo-wide (20 packages), and already tracked by the TEST_DEBT ledger and [P2] framework: 66 个包用 tsup 构建、无人做类型检查 —— 实测 18 个包共 380 处 code-tier 错误(#4118 的 framework 侧对应) #4311 — filing it again would be a duplicate.
origin/mainadvanced one commit (e5eeb499c) after my single merge. I left it; the merge queue rebuilds against currentmain.
Generated by Claude Code
Generated by Claude Code
- The one substantive change I made to the inherited commit is the reachability text in
ACCEPT — PR #8766.
The headline is the self-correction, and it vindicates treating the restart survivor as a draft
When the container restart killed the first dev, I re-dispatched with an instruction to treat its surviving commit as a draft, not finished work, specifically because I could not tell from outside whether it had ever answered the reachability question. That instruction earned its keep:
The previous draft of this branch asserted "on every new organization of a multi-tenant install"; that overstated what is visible here and has been narrowed, in both the file header and the changeset, along with the word "measured" — which described a static trace at the time it was written and now describes the run above.
So the pre-restart commit did overstate, and it overstated in exactly the direction I flagged: claiming a live defect the evidence did not reach. A resumed dev that had trusted its predecessor's commit would have shipped that claim into a changeset and from there into the release notes. Recording this because the general lesson is durable: a commit from an agent that died before pushing has unknown verification state, and its confident prose is the least trustworthy part of it — the code was at least type-checked, the claims were not.
The reachability verdict is the right shape
"A dormant asymmetry with a live route — not a live defect." That is a more useful answer than either of the two I offered, and it is honest in both directions:
- Nothing shipped produces such a row — both seeders run tenant-less,
normalizeManagedByVocabdoesn't touchsys_capability, the admin door refuses the stamp outright, and nosys_capabilityseed dataset exists in the repo. - The route is real and needs no unsupported step: the seed loader writes as
isSystem(short-circuiting that write gate),defineSeedtype-checksmanaged_by: 'platform', and a pinned organization short-circuits thesys_prefix exemption.
And it declines to guess where the repo genuinely cannot answer: "how many organizations a given deployment replays seeds into is a provisioning question this repository cannot answer." Right call — that is outside the tree.
Leg B is what makes leg A evidence. Pinning an organization produced the misplaced stamp; the unpinned control produced a correctly platform-scoped row, proving the prefix exemption is otherwise intact and the stamp is the pinning's doing rather than a fixture artifact. A single-leg measurement here would have proven nothing.
Verified rather than accepted
patch, notminor— checked independently:CapabilitySeedResultis declared atbootstrap-system-capabilities.ts:162and referenced only within that file; zero hits in the packageindex.ts, which carries 30 exports (so the grep is live, not a false zero). No consumer can read the type, and the sole caller ignores the result shape. The argument holds.- fix(plugin-security): count and warn the derived capability skip that leaves the platform bucket unseeded #8749 untouched, as ruled.
unseededDerivedkeeps its documented meaning as a strict subset ofskippedAuthored, and the misplaced stamp gets its ownplatformStampedInOrgcounter rather than being folded in. The reason given is the correct one: "the platform's definition is missing" and "a row wears the platform's stamp where no platform writer writes" are different facts, and the second deserves counting even when the first is false.
The test design defeats the two fixes that would have looked right
The positive controls are aimed, not decorative: "a 'fix' that declined everything, or that scoped the derived LOOKUP the way the curated one is scoped, passes the first group and fails these." Those are precisely the two plausible wrong answers — over-declining, and over-symmetrising with the curated half — and both are caught.
The reverse verification is non-uniform, which is the strong form: inverting the guard turned exactly 4 tests red — the two overwrite/silence pins, the message pin, and the adverse ordering leg only, while the benign leg stayed green. A uniform "everything went red" would have been much weaker evidence; the adverse/benign split is the anti-vacuity argument, because it shows the pins discriminate rather than merely react.
Also correct: the platform bucket is pinned ABSENT on purpose, per #8552's ruled no-adoption/no-backfill posture, so a future backfill has to fail this suite rather than quietly pass it.
Honest disclosure worth crediting
plugin-securityexcludes**/*.test.tsfrom its tsconfig, so the packagetypecheckdoes not cover the edited test file. Rather than hiding behind a green typecheck, the dev measured it with the exclusion lifted: 11 raw errors, exactly the frozenTEST_DEBTnumber, zero of them in this file. So the ledger stays accurate and the ratchet is not moved. That is the difference between a gate reading and a gate result.Housekeeping
- Docs-drift advisory (12 pages): no action. The check keys on package touched; the behaviour change is an internal seeder ownership predicate plus a counter on a non-exported type. No documented surface changes, so no page can have drifted.
- Gates were still running at review time. I'll take job conclusions — not an aggregate reading — before marking ready, then enable auto-merge in that order.
Generated by Claude Code
- Nothing shipped produces such a row — both seeders run tenant-less,
- added a commit that references this issue
on Aug 17, 2026
Found while implementing #8536 (PR #8749). That card scoped the DERIVED half's skip branch — the #5876 guard that
continues on a row it does not own. This is the neighbouring update branch, which reaches the same "platform bucket unseeded" end state by a different route and is not covered by #8749's new counter or warning. Filing rather than fixing: out of #8536's scope, and the severity call belongs to triage.The mechanism
In
packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts, the derived half's lookup is{ name }underSYSTEM_CTX(notenantId), so it reads across organizations. The ownership guard is a test onmanaged_byalone:managed_by: 'platform'was a sufficient ownership test whennamewas unique installation-wide. Since #8461 made it unique per ORGANIZATION (ADR-0120 D1), a platform-marked row can sit inside an organization's bucket — the file's own header already contemplates this shape, "a platform-marked row sitting inside an organization — from seed data or a legacy import". Such a row passes the guard, so the seeder updates it and never inserts into the NULL-organization bucket.This is the same asymmetry #8470 fixed for the CURATED half, whose predicate is the conjunction
managed_by: 'platform'ANDorganization_id: nullprecisely becausemanaged_byalone "would not carry that guarantee". The derived half kept the single-condition test.Measured
In-memory double mirroring
sys-capability.object.ts's unique key, one pre-existing row{ organization_id: 'org_x', name: 'showcase.export_data', managed_by: 'platform', label: 'Stale Org Label' }, one grant naming that capability:Two facts in one run: the platform bucket is never seeded, and an organization's row has its
label/descriptionoverwritten by a generated placeholder. Every counter reads zero and nothing is logged, so this state is invisible in the boot summary as well as in the new diagnostic.Why it is distinct from #8536
#8536 and #8749 are about the branch where the guard declines. Here the guard admits the row, so no skip is counted and the new
unseededDerivedcounter is never incremented. A fix would be a change to the lookup or the guard predicate, not to observability — which is why it had no place in a card explicitly ruled "parity, not policy; no behaviour change beyond observability".Note also that the overwrite half is arguably the more interesting one: #5876's whole point was that the derived half must never rewrite an author's copy with
humanize(name), andmanaged_by: 'platform'was the test chosen to enforce it. Post-#8461 that test admits a row an organization holds.Consequence, and what is NOT claimed
Unmeasured beyond the above, and probably the same as #8536's: no runtime path reads a
sys_capabilityrow (grants andrequiredPermissionsresolve by name;validateCapabilityReferencesresolves againstPLATFORM_CAPABILITY_NAMESplus stack declarations), so the effect should be confined to registry contents and Setup listings. I did not re-derive that claim here — I am carrying it across from #8536's body, which established it for the sibling branch.Reachability of a platform-marked row inside an organization is likewise not measured. The file header names seed data and legacy imports as the sources; whether either actually produces one on a shipped install is the open question that decides whether this is a defect or a dormant asymmetry.
Filed unassigned, observation-class. Related to #8536 / PR #8749; not blocked by either.
Generated by Claude Code