Repository navigation
sys_capability.active is read by nothing — the Deactivate action tells the admin grants stop resolving, and they do not #8535
Description
Activity
Triage →
needs-user-decision+security+domain:metadata(likeliest landing:sys-capability.object.tsinpackages/platform-objects; the enforce option would instead land across the authz gates). Type: Bug — a shipped confirmation dialog declares behaviour ("grants … stop resolving") that no code path enforces: declared ≠ enforced.Human floor, not auto-adjudicable: security/permission boundary, and the cheap option removes a published capability — both are hard floors regardless of prism alignment.
Premises (re-check before ruling):
- No reader of
active:git grep -n "'sys_capability'" origin/main -- packages→ two seeders only, neither on an authz path (filer measured on30f1b7488d). - Dialog text still ships:
git grep -n "stop resolving" origin/main -- packages/platform-objects. 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 (same seeder, different defect) is in flight — re-check its landed shape before implementing either direction.
Question: enforce
sys_capability.activeon the authorization path, or remove/reword the Deactivate claim?Options:
- A — Enforce. Capability resolution consults the row; an inactive capability stops satisfying
requiredPermissions. Makes the registry load-bearing on the authz hot path — caching, fail-closed semantics, and org-authored-row influence over platform capabilities (needsbootstrapSystemCapabilitiesreconciles 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's ownership scoping carried through) all become live questions. - B — Remove/reword the claim. Drop the
deactivate_capabilityaction +activefield, or reword dialog/field to what it is (a catalogue/visibility flag). Honest and cheap; authz stays string-set based.
Four prisms:
- Platform long-term coherence: B shrinks a false surface; A adds a second authorization source of truth alongside string sets — a real architecture change that deserves its own ADR if ever wanted, not a bug-fix rider.
- Measured business pull: zero — nothing reads
active, and the failure is silent by construction, so no one has hit it. Pull for A is speculative capability-lifecycle management. - AI-agent error-resistance: today's shape is the canonical trap — a declared control the runtime does not honour. B restores declared = enforced by deleting the declaration; A restores it by building enforcement. Both beat the status quo; B is structurally harder to get wrong.
- Startup scope discipline: B removes; A is declare-and-maintain on the hottest path in the system.
Recommendation: B — remove the action and the
activefield (or reword to an explicit catalogue flag if Setup wants the badge), per ADR-0049 enforce-or-remove. If enforcement is ever wanted, it arrives as its own ADR with the fail-closed/caching questions answered, on top of #8470's ownership scoping.Dependency note: finding #8536's re-grade is gated on this ruling (its unseeded-platform-bucket severity flips with the direction chosen).
Generated by Claude Code
- No reader of
Maintainer ruling recorded — option B: remove the false claim (ADR-0049 enforce-or-remove). Provenance: maintainer live session, 2026-08-13 ~23:50Z, verbatim 「接受你的全部建议」, accepting this seat's four-prism briefing of the full decision box (this card's briefed recommendation was B).
The ruling, spelled out for dispatch:
- Withdraw the Deactivate action's promise. Default implementation shape: reword the confirmation dialog and field semantics to what
activeactually is today (a catalogue/visibility flag with no authorization effect), and demote it fromhighlightFields/list-view prominence accordingly. Full removal of the action + field is the alternative shape — if the lane prefers it, it goes through thespec-property-retirementplaybook (ADR-0049/0087 discipline), not an ad-hoc delete. - Option 1 (enforce) is rejected for now: putting the registry on the authorization hot path is an architectural change (caching, fail-closed, org-authored rows influencing platform capabilities) that must arrive as a designed feature with its own card if capability lifecycle management ever earns real pull — not as a side effect of wiring up one field.
- Either shape must leave no surface claiming deactivation affects grant resolution — dialog text, field description, and generated reference pages all checked at fix time.
Label transition:
needs-user-decision→pm:queue. Type Bug stands (a published claim contradicting enforced behaviour).Consequence for #8536: its first named re-grade trigger (this ruling) has fired — re-graded in a separate comment there.
Recorded by the triage seat on the maintainer's direct channel.
Generated by Claude Code
- Withdraw the Deactivate action's promise. Default implementation shape: reword the confirmation dialog and field semantics to what
Claim — dev seat starting implementation.
- Session:
session_012WMpuAfA2KSdDjGF6tm1bH - Branch:
claude/issue-8535-deactivate-claim(worktree../objectstack-issue-8535, offb45c71e85a) - Shape: R1 reword (option B, default shape). Not the retirement playbook — reasoning will be in the PR.
File surface I expect to touch (declared up front so parallel seats can see overlap):
packages/plugins/plugin-security/src/objects/sys-capability.object.ts—deactivate_capability.confirmText,activefielddescription,highlightFields, the threelistViews[*].columnspackages/plugins/plugin-security/src/translations/{en,es-ES,ja-JP,zh-CN}.objects.generated.ts— leaf values only, all four locales carry a translated copy of the claim- a new test file under
packages/plugins/plugin-security/src/— the no-surface-claims-this sweep .changeset/*.md
Disjointness with the live sibling seats:
#8552holds thesys_capabilityauthoring write door (curated-name refusal at create/rename). I touch no validation path and no seeder — only presentation metadata on the same object file plus its bundles. If I find myself in a shared validation path I stop and escalate rather than race it.#8577was told to check before touchingplugin-security/src/translations/*. I am taking those four bundle files for thesys_capability._actions.deactivate_capability.confirmTextandfields.active.helpleaves. Flagging explicitly.#8470(5c04b2a9, PR fix(security): the curated capability seeder reconciles the row the platform owns, not whichever row shares the name (#8470) #8537) has landed; its curated-lookup scoping andblockedCuratedare inbootstrap-system-capabilities.ts, which I do not edit.
Premise re-verification (measured on
origin/main@b45c71e85a, not inherited):- No reader of
active— still true. Two production writers (bootstrap-declared-capabilities.ts:230,bootstrap-system-capabilities.ts:258), bothactive: trueon insert; zero readers.getSystemPermissionsunionspermissionSets[].systemPermissionsstrings; the lint resolves seed rows bynameonly. - Dialog text still ships —
packages/plugins/plugin-security/src/objects/sys-capability.object.ts:72, plus a translated copy in all four shipped locales.
Generated by Claude Code
- Session:
{ "issue": 8535, "status": "done", "branch": "claude/issue-8535-deactivate-claim", "pr": "https://github.com/objectstack-ai/objectstack/pull/8619", "premise_still_valid": true, "summary": "Both premises re-verified on origin/main at b45c71e85a, not inherited. (1) No reader of `active` exists: getSystemPermissions() unions permissionSets[].systemPermissions as plain strings and requiredPermissions is matched against that string set — no row load; the table's only two production readers are the seeders, which WRITE active:true on insert and never read it back. (2) The dialog text still shipped, in the source AND in all four locales. Took the reword shape (R1 default), not the retirement playbook: that playbook governs authorable packages/spec properties (liveness ledger, ADR-0087 conversion, retiredKey() tombstones), whereas sys_capability.active is a shipped DB column with its own index on a plugin-owned system object, written by two seeders — removal is a data migration, and it would touch bootstrap-system-capabilities.ts, which #8470 landed in and which this PR leaves untouched. Changed: dialog now states the non-effect outright rather than merely omitting the promise; the same correction in all four locales; `active` gains a description it never had (its absence is how the dialog became the only place the field's meaning was stated); demoted from highlightFields, from the `danger` variant, and from the two scoped list views, kept in all_capabilities. The i18n trap fired exactly as warned and is documented in the PR: `--write` ADDED the new active.help key to all four bundles but left the CHANGED confirmText stale in all four including en, so the eight leaf values were hand-corrected. check:i18n reports 'in sync' either way, so it is not evidence here — the sweep test is.", "tests": "New sweep `sys-capability-deactivation-claim.test.ts` (19 cases): walks every string in the object definition and in all four locale bundles, applying a NEGATIVE check (the withdrawn claim's own words, per locale, appear nowhere) and a POSITIVE one (each locale's dialog and field help actually STATE the non-effect — catches the cheap non-fix of deleting the sentence). Ablations, colour predicted before each run, all matching: old English text back in the SOURCE only -> predicted 2 red with all bundle cases green, measured exactly that; old ja-JP text back in the BUNDLE only -> predicted 2 red (ja-JP only), measured exactly that; `active` back in highlightFields -> predicted 1 red, measured 1. The first two prove the bundle assertions read the bundle files independently of the source — precisely the blind spot that let a corrected source ship beside a stale en bundle in the #8601 case. Non-vacuity also pinned by case count (1+4+1+4+1+4+1+2+1 = 19), so no it.each silently expanded to zero. Fix committed BEFORE the ablations so each restore came out of a real commit; tree verified clean against the commit after each. Gates green after the final commit: plugin-security 57 files / 1103 tests passed, `tsc --noEmit` clean; check:i18n 'OK (9 packages — all bundles in sync)' on a BUILT CLI; check:nul-bytes OK (7759 files) plus a control-byte self-scan of every touched file; check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check:pm-dispatch-gates, check:query-options-erasure, check:type-check-coverage, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset. check:type-check-debt run in all three modes: plain, --self-test, and --re-measure — 'OK, 33 ledger entries re-measured, none above its recorded number; surplus: none', i.e. no ledger growth. --re-measure first failed on a stated PREREQUISITE (an unbuilt nested closure, @objectstack/service-knowledge); rebuilt with lint.yml's exact filters ('./packages/*' + './packages/*/*') and re-ran green — that first result meant nothing, as the script's own error text says.", "open_questions": [ { "question": "How far should the demotion from list-view prominence go? The ruling says 'demote from highlightFields / list-view prominence accordingly' without fixing the boundary, and I made a judgment call inside it.", "options": [ "A (what I shipped) — out of highlightFields, out of the `danger` variant, out of the two SCOPED views (platform, org); kept in all_capabilities, the full-catalogue view.", "B (stricter) — also drop it from all_capabilities, so `active` is settable via the action but appears in no grid.", "C (further) — also rename the field LABEL from 'Active' to something non-authorization-sounding (e.g. 'Listed'), which would add four more locale strings." ], "recommendation": "A, because a catalogue attribute belongs in the catalogue view and stays observable and filterable for the admin who sets it; B is the opposite error — a flag the product lets you set but never lets you see is its own dishonest surface. I did not take C: 'Active' is not itself a claim about grant resolution (the dialog was), and a label rename ripples four locales for little gain. Both are cheap to change if you read the ruling more strictly — the demotion is pinned by tests, so flipping it is a visible, reviewable edit." } ], "out_of_scope_findings": [ "filed as #8613: `sys_permission_set.active` and `sys_position.active` are unenforced in exactly the same way, and both Deactivate dialogs promise access stops — no `active` predicate exists anywhere in the resolution chain (resolvePermissionSetsForContext, resolvePermissionSets' three sources, or the DB loader's `where`, which also does not carry `active` onto the mapped PermissionSet). Worse there than here, since a permission set is the grant itself. Filed unassigned, unlabeled for triage; its direction is deliberately NOT assumed to follow this card's ruling, since unlike a capability catalogue it has a plausible enforcement point and plausible business pull." ] }Not asked about, worth stating:
- Nothing [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 holds was touched. It holds the
sys_capabilityauthoring write door (curated-name refusal at create/rename). My diff touches no validation path, no seeder and no write door — only presentation metadata on the shared object file (highlightFields, one action'svariant/confirmText, list-viewcolumns, one fielddescription) plus the four bundles. No shared file or validation path was entered, so no stop-and-escalate was triggered. Note we do both editsys-capability.object.ts, so a textual merge conflict there is possible even though the semantic surfaces are disjoint. plugin-security/src/translations/*were taken, as flagged in my claim comment for Two MORE clean inheritors of the #8323 class the #8554 sweep missed: sys_notification_subscription and sys_audience_binding_suggestion — both measured live #8577's benefit — eight leaf values across the four bundles, all inside thesys_capabilitysubtree.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's landed shape was read and left alone (5c04b2a9/ PR fix(security): the curated capability seeder reconciles the row the platform owns, not whichever row shares the name (#8470) #8537): the curated lookup scoped tomanaged_by: 'platform'+organization_id: nullandblockedCuratedlive inbootstrap-system-capabilities.ts, which this PR does not edit.- Rebased onto current
main(8123eb71a5, fix(pm): declare the type-check ledger's root-program coupling so dispatch-gates can derive it (#8551) #8603 landed mid-run, unrelated to my files); gate union re-derived and re-run after the final commit. - Generated reference pages: none exist for this object. I checked for a docs generator over platform objects (
scripts/has nogen:producingcontent/docs/references/**;regen-artifacts.mjsowns only spec artifacts) and sweptcontent/docsfor the claim. The.generated.tstranslation bundles ARE the generated surface here, and they are the four files corrected. - The old claim text still appears in three places by design — the changeset quoting what is withdrawn, the test's forbidden-pattern registry, and a source comment recording the old wording. None is a shipped surface; a whole-repo grep confirms no other occurrence outside historical CHANGELOGs.
Generated by Claude Code
- Nothing [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 holds was touched. It holds the
- added a commit that references this issue
on Aug 17, 2026
Found while implementing #8470, which required establishing whether any runtime path resolves a
sys_capabilityrow (as opposed to the bare capability name string). The answer is no — and that answer has a consequence beyond #8470.The claim the product makes
sys-capability.object.tsships adeactivate_capabilityrecord action whose confirmation dialog reads, verbatim:The
activefield is also surfaced in all three list views and inhighlightFields, so deactivation presents as a first-class administrative control.What actually happens
Nothing reads it. Measured on
origin/mainat30f1b7488d:requiredPermissionscompares string sets.PermissionEvaluator.getSystemPermissions()unionspermissionSets[].systemPermissions(strings);normalizeRequiredPermissionsreads the resource's own declared strings. Neither loads a row.find/findOne/count/aggregateagainst'sys_capability', plus every consumer of theplatform-object-namesentry — returns exactly two production call sites, both seeders:bootstrap-system-capabilities.tsandbootstrap-declared-capabilities.ts. Both readname,managed_by,package_id. Neither readsactive, and neither is on an authorization path.validateCapabilityReferences(the authoring lint) resolves againstPLATFORM_CAPABILITY_NAMES, stack declarations and seed-data records — the spec constant and the config, never the table.So
active: falsechanges no authorization decision, no lint verdict, and no runtime resolution. It changes a column value and a badge in Setup.Why this is worth a card rather than a shrug
The direction of the falsehood is the bad one. An admin who wants to withdraw a capability is offered a control that states, in a confirmation dialog, that withdrawing it takes effect — and the withdrawal silently does not happen. That is a security-relevant misstatement about a security surface even though it is not itself a privilege escalation: the escalation is what the admin believes they prevented.
This is also an ADR-0049 enforce-or-remove shape: a declared field with a declared meaning and no enforcement.
The two directions, not a recommendation
I am deliberately not choosing here, because the choice is a contract decision:
requiredPermissions. This makes the registry load-bearing on the authorization hot path, which is a real architectural change (every gate needs the row, with the caching and fail-closed questions that implies) and it gives an org-authored row influence over a platform capability unless the ownership scoping frombootstrapSystemCapabilitiesreconciles 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 is carried through.Filed unassigned. Related to #8470, which fixes a different defect in the same seeder and does not touch this.
Generated by Claude Code