You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
* fix(plugin-security): protect platform/package-stamped sys_position rows (#2926 ①)
The system-row write gate (#2918/#2930) keyed sys_position provenance on the
legacy managed_by values (system/config), but the A4 vocab unification (#2934)
stamps and boot-normalizes rows to platform/package — so the gate silently
stopped firing for positions, letting admins physically delete the everyone/
guest audience anchors once their bindings were removed. Guard both the
canonical and legacy vocabularies, update the gate tests to the canonical
values, add legacy-row regressions, and correct the stale 'no runtime path
branches on legacy values' safety comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* fix(rest): pass through explicit 4xx status+code in mapDataError (#2926 ⑦)
Record-scope authorization denials (plugin-sharing throws status=403,
code=FORBIDDEN) degraded to a bare 400 with no code because the generic data
routes call mapDataError directly, bypassing sendError's status passthrough.
Add a guarded 4xx passthrough after the structured-code branches (409
envelopes keep their rich fields; 5xx still goes through the sanitizing
heuristics; oversized messages fall back to generic text), and stop logging
expected statuses as unhandled on the list route.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* feat(metadata-protocol): reject unknown $-prefixed query params with 400 (#2926 ⑩)
Unsupported $ parameters (e.g. $foo, $inlinecount) used to fall into the
implicit-filter bucket and silently match zero rows — and before the $filter
alias existed, were dropped entirely, returning the unfiltered first page to
callers that believed they had a filtered result set. All supported aliases
are consumed before the guard, so anything $-prefixed that remains is a hard
400 UNSUPPORTED_QUERY_PARAM naming the offending keys and the supported list.
Bare-key implicit equality filters are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* feat(plugin-sharing): backfill rule grants at boot (#2926 ③)
Sharing-rule grants are materialized by write hooks that deliberately skip
isSystem writes, so seed-loader records never got sys_record_share rows —
demo data with matching rules was broken on a fresh deploy until each record
was touched at runtime. Reconcile every active rule once per boot (idempotent,
best-effort per rule) right after the rule hooks bind.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* feat(plugin-sharing): sys_sharing_rule provenance + seed-not-clobber (#2909 P0/T1)
sys_sharing_rule is record-authoritative (ADR-0094 addendum): declared rules
are a boot seed, the row is the authority — but every boot re-ran a clobbering
upsert, so an admin's active:false on an over-sharing rule was silently
resurrected on redeploy. Add readonly managed_by (A4 tri-state) + customized
provenance columns, put defineRule in seed mode when the bootstrap passes
managedBy:'package' (pristine/legacy rows adopted and updated; admin-authored
or customized rows untouched), and stamp customized via a beforeUpdate hook on
any non-system edit of a seeded rule. No write gate on purpose — sharing rules
stay a first-class admin authoring surface; edits are remembered, not blocked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* test(plugin-security): lock bootstrap-declared-positions seed semantics (#2909 T2)
sys_position is record-authoritative — the declared seeder refreshes only
label/description and must never touch bindings, active, is_default,
delegatable, or managed_by. That behavior predates these tests but was never
locked; regressions would silently clobber admin state at every boot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* fix(plugin-security): stop clobbering admin-edited capability scope (#2909 T3)
scope is an admin-editable classification select on sys_capability, but the
curated seeder refreshed it every boot — silently reverting admin
reclassifications. Make it seed-once (insert only); label/description remain
platform-owned and keep refreshing. Lock both sides with tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* docs(adr): resolve 0094 addendum audit items; record sharing-rule seed-not-clobber tradeoff (#2909 T4)
The three 'Audit that…' per-type rows are now shipped decisions:
sys_sharing_rule = record-authoritative with provenance + seed-not-clobber;
sys_position = seed-only identity/display, locked by test; sys_capability =
seed-not-clobber with scope seed-once. Record why sharing rules got no write
gate (admin tuning surface), the customized-stamp boundaries, and the scope
migration cost.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* feat(app-showcase): bind persona positions on kernel:listening + announcements; document isDefault dual-track (#2926 ②⑤)
A fresh deploy booted with ZERO position↔permission-set bindings — every
persona silently degraded to the everyone baseline. Bindings are
record-authoritative (ADR-0090/0094) and cannot be a declarative seed (the
seed loader runs before the security bootstrap creates sys_position /
sys_permission_set, so name references can't resolve). They are ensured
imperatively by registerShowcasePositionBindings on kernel:listening — the
phase that fires only after every kernel:ready handler (incl. the security
bootstrap) has settled, so the referenced rows exist. everyone→member_default
is bound here too: the framework only auto-binds an app's isDefault set to
everyone when it is application-owned; the showcase ships as a package, so its
default lands in sys_audience_binding_suggestion (pending admin confirmation)
and is not live until confirmed.
Also seeds showcase_announcement demo rows and documents the isDefault
dual-track (app-level auto-bind vs package-level suggestion) in the spec.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
* fix(plugin-sharing): defer rule backfill to kernel:listening so seed rows materialize (#2926 ③)
The boot backfill ran inside a kernel:ready handler, but SeedLoader also
seeds on kernel:ready (raced against a budget, in a different AppPlugin
handler). Since kernel:ready handlers fire sequentially in registration
order, the backfill could run before the seed records exist and materialize
nothing. Move the reconcile to kernel:listening (Phase 4), which the kernel
fires only after every kernel:ready handler has settled — so the seeded rows
are present. Verified end-to-end: a seeded red project now yields a
sys_record_share to the exec recipient at boot, no runtime touch required.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(metadata-protocol): findData now rejects unknown `$`-prefixed query parameters with 400 `UNSUPPORTED_QUERY_PARAM` instead of silently treating them as implicit field-equality filters that match zero rows (#2926 ⑩). A `$`-prefixed key can never be a field name, so this is loud-failure only for the unsupported-alias class; bare-key implicit equality filtering is unchanged. The error message lists the supported aliases ($top, $skip, $orderby, $select, $count, $search, $searchFields, $filter, $expand).
fix(rest): mapDataError now honors an explicit 4xx `error.status`/`error.code` carried by domain errors (#2926 ⑦). Record-scope authorization denials from plugin-sharing (status 403, code FORBIDDEN) previously degraded to a bare 400 with no machine-readable code because the generic data routes bypass sendError's status passthrough. Structured 409 envelopes (CONCURRENT_UPDATE, DELETE_RESTRICTED) keep their dedicated branches; 5xx statuses still go through the message-sanitizing heuristics.
fix(plugin-security): stop clobbering admin-edited capability `scope` on boot (#2909 T3). `scope` is an admin-editable classification select on sys_capability, but the curated seeder refreshed it on every boot alongside label/description — silently reverting admin reclassifications. It is now seed-once: written on insert, never refreshed (a curated scope change in a new platform version requires a data migration; recorded in the ADR-0094 addendum).
fix(plugin-security): re-arm the sys_position system-row write gate after the A4 managed_by rename (#2926 ①). The gate's provenance map still keyed on the legacy `system`/`config` values while rows are now stamped (and boot-normalized to) `platform`/`package`, so platform/package-managed positions — including the `everyone`/`guest` audience anchors — could be physically deleted through the data API once their bindings were removed. The map now guards both the canonical and legacy vocabularies, and the misleading "no runtime path branches on legacy values" safety notes were corrected.
feat(plugin-sharing): sys_sharing_rule provenance + seed-not-clobber (#2909 P0/T1). The object gains readonly `managed_by` (unified A4 tri-state platform/package/admin) and `customized` columns; declared rules seed with `managed_by: 'package'`. defineRule in seed mode adopts pristine/legacy rows (package upgrades stay deliverable) but never overwrites admin-authored or customized rows — an admin's `active: false` on an over-sharing rule now survives redeploys instead of being resurrected at boot. A beforeUpdate hook stamps `customized` on any non-system edit of a seeded rule. Deliberately NO write gate: sharing rules remain a first-class admin authoring surface (ADR-0094 addendum tradeoff).
fix(plugin-sharing): reconcile every active sharing rule once at boot (#2926 ③). Rule grants are materialized by write hooks, which deliberately skip `isSystem` writes — so seed-loader records never produced `sys_record_share` rows and demo data shipping with matching sharing rules was broken out of the box until each record was touched at runtime. The boot backfill runs on `kernel:listening` — the phase the kernel fires only after every `kernel:ready` handler has settled, including the AppPlugin seed loader — so the reconcile sees the seeded rows rather than racing them. It is idempotent (diff-based reconcile) and best-effort per rule so one broken rule cannot block startup.
docs(spec): rewrite the `isDefault` permission-set docs to describe the actual dual-track behavior (#2926 ②): app-level `isDefault` sets are resolved as the SecurityPlugin's fallback and idempotently auto-bound to the `everyone` anchor at boot (guarded by the high-privilege-bits check), while package-level sets are never auto-bound and instead materialize a `sys_audience_binding_suggestion` an admin confirms. The previous "never auto-bound" wording contradicted the shipped app-level track.
Copy file name to clipboardExpand all lines: docs/adr/0094-sys-permission-set-pure-projection.md
+29-5Lines changed: 29 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -301,14 +301,38 @@ only a seed-not-clobber discipline.
301
301
| Type | Enforcement reads | Class | Decision |
302
302
| :-- | :-- | :-- | :-- |
303
303
|`sys_permission_set`| metadata (`PermissionEvaluator.resolvePermissionSets` → `metadata.list('permission')`, DB row only as fallback) | metadata-authoritative |**Record is a projection — done** (this ADR). |
304
-
|`sys_sharing_rule`| the record, live (`sharing-plugin.ts` "rule evaluation reads `sys_sharing_rule` live"; `sharing-rule-service``engine.find`) |**record-authoritative**|Declared rules are a **boot seed**; the record is the authority. Do **not** project. Audit that `bootstrapDeclaredSharingRules` preserves env-edited rows (seed-not-clobber) and that the metadata overlay is not read as a live override. |
305
-
|`sys_position`| mixed — position→permission-set resolution is metadata-first for the *sets*, but the `sys_position`**record** (incl. its `permissions` field, bindings, `delegatable`, `admin` gating) is read live by the anchor gate and `DelegatedAdminGate`|**needs the seed-vs-authority audit**against the criterion above; likely record-authoritative for bindings with metadata seeding identity | Classify precisely, then either project (if the position *definition* is enforced from metadata) or make declared positions seed-only. |
306
-
|`sys_capability`| the record (curated registry read for capability existence) | record-authoritative (registry) |Seed-only; low authoring surface. Audit seed-not-clobber. |
304
+
|`sys_sharing_rule`| the record, live (`sharing-plugin.ts` "rule evaluation reads `sys_sharing_rule` live"; `sharing-rule-service``engine.find`) |**record-authoritative**|**Resolved (#2909 P0/T1)**: declared rules are a **boot seed**; the record is the authority; not projected. Rows carry readonly `managed_by` (unified A4 tri-state) + `customized` provenance; the seeder (`defineRule` in seed mode, `managedBy:'package'`) adopts pristine/legacy rows and keeps updating them, but **never overwrites admin-authored or `customized` rows** — an admin's `active:false` on an over-sharing rule survives redeploys. A `beforeUpdate` hook stamps `customized` on any non-system edit of a seeded row. |
305
+
|`sys_position`| mixed — position→permission-set resolution is metadata-first for the *sets*, but the `sys_position`**record** (incl. its `permissions` field, bindings, `delegatable`, `admin` gating) is read live by the anchor gate and `DelegatedAdminGate`|**record-authoritative**(bindings and lifecycle), with metadata seeding **identity + display only**|**Resolved (#2909 T2)**: declared positions are seed-only — `bootstrapDeclaredPositions` refreshes `label`/`description` and nothing else; bindings/`active`/`is_default`/`delegatable`/`managed_by` belong to the runtime/admin. Locked by `bootstrap-declared-positions.test.ts`. |
306
+
|`sys_capability`| the record (curated registry read for capability existence) | record-authoritative (registry) |**Resolved (#2909 T3)**: seed-not-clobber holds — `label`/`description` are platform-owned and refresh each boot; `managed_by`/`active` were already preserved; `scope` is an admin-editable classification face and is now **seed-once** (insert only). Locked by `bootstrap-system-capabilities.test.ts`. |
307
307
308
308
Only `sys_permission_set` was both metadata-authoritative **and** carried a
309
309
harmful, actively-drifting split-brain, which is why it was fixed first and in
310
-
full. The others are recorded here with their class so the follow-up work is
311
-
scoped, not rediscovered — tracked in framework#2909.
310
+
full. The follow-up work above landed via framework#2909.
311
+
312
+
#### Recorded tradeoffs (#2909)
313
+
314
+
-**Why `sys_sharing_rule` chose seed-not-clobber over a write gate
315
+
(projection-lite).** A self-consistent alternative existed: gate
316
+
package-managed rows against admin writes (as #2918 does for
317
+
`sys_position`/`sys_capability`), making the boot overwrite harmless. It was
318
+
rejected because sharing rules are a first-class admin authoring *and
319
+
tuning* surface in Setup — including narrowing or deactivating a seeded
320
+
rule that turned out to over-share. Locking package rows would amputate
321
+
that product capability, and the classification criterion (enforcement
322
+
reads the record) already points at record authority. Admin edits are
323
+
therefore *remembered* (`customized`), not blocked. Only the two provenance
324
+
columns themselves are `readonly` (engine-stripped from non-system
325
+
payloads), so provenance cannot be forged or cleared through the data door.
326
+
-**Known boundaries of the `customized` stamp**: multi-row updates (no
327
+
single `input.id`) are not stamped — every rule-editing UI path updates by
328
+
id; an admin who *deletes* a package rule gets it back pristine on the next
329
+
boot (delete ≠ customize; deactivation is the supported way to retire a
330
+
seeded rule); a "reset to package default" affordance (system-context clear
331
+
of `customized`) is future work.
332
+
-**`sys_capability.scope` is seed-once**: a curated scope change shipped in
333
+
a new platform version no longer propagates to existing rows and needs a
334
+
data migration. Accepted — silently reverting an admin's reclassification
* with data, so its assertions stop dry-running on a fresh DB. The object has
300
+
* NO `name` field (display name derives from `title`); `owner_id` stays
301
+
* unset — users can't be seeded, and creation rights are deliberately narrow
302
+
* (only `showcase_ops` may create; everyone else is read-only by design).
303
+
*/
304
+
constannouncements=defineSeed(Announcement,{
305
+
mode: 'upsert',
306
+
externalId: 'title',
307
+
records: [
308
+
{title: 'Welcome to the Showcase workspace',body: 'This demo org exercises the full permission model: positions, permission sets, sharing rules and field-level security. Log in as different personas to compare what each can see and edit.'},
309
+
{title: 'Q3 field-ops rollout',body: 'Field Operations onboards the new inquiry intake flow this quarter. New public inquiries are shared automatically with the Field Ops subtree.'},
0 commit comments