Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/protocol-2926-unknown-dollar-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata-protocol': patch
---

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).
5 changes: 5 additions & 0 deletions .changeset/rest-2926-status-passthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/rest': patch
---

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.
5 changes: 5 additions & 0 deletions .changeset/security-2909-capability-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-security': patch
---

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).
5 changes: 5 additions & 0 deletions .changeset/security-2926-position-gate-vocab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-security': patch
---

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.
6 changes: 6 additions & 0 deletions .changeset/sharing-2909-seed-not-clobber.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@objectstack/plugin-sharing': minor
'@objectstack/spec': patch
---

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).
5 changes: 5 additions & 0 deletions .changeset/sharing-2926-boot-backfill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-sharing': patch
---

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.
5 changes: 5 additions & 0 deletions .changeset/spec-2926-isdefault-dualtrack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/spec': patch
---

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.
34 changes: 29 additions & 5 deletions docs/adr/0094-sys-permission-set-pure-projection.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,14 +301,38 @@ only a seed-not-clobber discipline.
| Type | Enforcement reads | Class | Decision |
| :-- | :-- | :-- | :-- |
| `sys_permission_set` | metadata (`PermissionEvaluator.resolvePermissionSets` → `metadata.list('permission')`, DB row only as fallback) | metadata-authoritative | **Record is a projection — done** (this ADR). |
| `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. |
| `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. |
| `sys_capability` | the record (curated registry read for capability existence) | record-authoritative (registry) | Seed-only; low authoring surface. Audit seed-not-clobber. |
| `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. |
| `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`. |
| `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`. |

Only `sys_permission_set` was both metadata-authoritative **and** carried a
harmful, actively-drifting split-brain, which is why it was fixed first and in
full. The others are recorded here with their class so the follow-up work is
scoped, not rediscovered — tracked in framework#2909.
full. The follow-up work above landed via framework#2909.

#### Recorded tradeoffs (#2909)

- **Why `sys_sharing_rule` chose seed-not-clobber over a write gate
(projection-lite).** A self-consistent alternative existed: gate
package-managed rows against admin writes (as #2918 does for
`sys_position`/`sys_capability`), making the boot overwrite harmless. It was
rejected because sharing rules are a first-class admin authoring *and
tuning* surface in Setup — including narrowing or deactivating a seeded
rule that turned out to over-share. Locking package rows would amputate
that product capability, and the classification criterion (enforcement
reads the record) already points at record authority. Admin edits are
therefore *remembered* (`customized`), not blocked. Only the two provenance
columns themselves are `readonly` (engine-stripped from non-system
payloads), so provenance cannot be forged or cleared through the data door.
- **Known boundaries of the `customized` stamp**: multi-row updates (no
single `input.id`) are not stamped — every rule-editing UI path updates by
id; an admin who *deletes* a package rule gets it back pristine on the next
boot (delete ≠ customize; deactivation is the supported way to retire a
seeded rule); a "reset to package default" affordance (system-context clear
of `customized`) is future work.
- **`sys_capability.scope` is seed-once**: a curated scope change shipped in
a new platform version no longer propagates to existing rows and needs a
data migration. Accepted — silently reverting an admin's reclassification
every boot was the worse failure mode.

### Why an addendum, not a new ADR

Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ShowcaseExternalDatasource } from './src/system/datasources/showcase-ex
import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/index.js';
import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js';
import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js';
import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js';
import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews } from './src/ui/views/index.js';
import { ShowcaseApp } from './src/ui/apps/index.js';
import { ChartGalleryDashboard, OpsDashboard } from './src/ui/dashboards/index.js';
Expand Down Expand Up @@ -218,4 +219,7 @@ export const onEnable = async (ctx: unknown): Promise<void> => {
await setupShowcaseExternalDatasource(ctx as Parameters<typeof setupShowcaseExternalDatasource>[0]);
// Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action.
registerRecalcEndpoint(ctx as Parameters<typeof registerRecalcEndpoint>[0]);
// [#2926 ②] Ensure the persona position↔permission-set bindings exist after
// the security bootstraps (cannot be a seed — see bind-position-sets.ts).
registerShowcasePositionBindings(ctx as Parameters<typeof registerShowcasePositionBindings>[0]);
};
26 changes: 25 additions & 1 deletion examples/app-showcase/src/data/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Product, Invoice, InvoiceLine } from '../objects/invoice.object.js';
import { Contact } from '../objects/contact.object.js';
import { Inquiry } from '../objects/inquiry.object.js';
import { FieldZoo } from '../objects/field-zoo.object.js';
import { Announcement } from '../objects/announcement.object.js';

/**
* Seed data sized to "feed every view": every Kanban column is populated,
Expand Down Expand Up @@ -161,6 +162,13 @@ const orgUnits = SeedSchema.parse({
],
});

// [#2926 ②] Position ↔ permission-set bindings are NOT seeded here: the seed
// loader runs before the security bootstrap creates the sys_position /
// sys_permission_set rows, so the required name references cannot resolve.
// They are ensured imperatively on kernel:listening instead (after every
// kernel:ready handler, incl. the security bootstrap, has settled) — see
// `src/security/bind-position-sets.ts` (wired via `onEnable`).

const teams = defineSeed(Team, {
mode: 'upsert',
externalId: 'name',
Expand Down Expand Up @@ -286,4 +294,20 @@ const preferences = defineSeed(Preference, {
],
});

export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences];
/**
* [#2926 ⑤] Announcements — the read-visibility demo object finally ships
* with data, so its assertions stop dry-running on a fresh DB. The object has
* NO `name` field (display name derives from `title`); `owner_id` stays
* unset — users can't be seeded, and creation rights are deliberately narrow
* (only `showcase_ops` may create; everyone else is read-only by design).
*/
const announcements = defineSeed(Announcement, {
mode: 'upsert',
externalId: 'title',
records: [
{ 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.' },
{ 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.' },
],
});

export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences, announcements];
118 changes: 118 additions & 0 deletions examples/app-showcase/src/security/bind-position-sets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#2926 ②] Position ↔ permission-set bindings for the showcase personas.
*
* The permission model is record-authoritative (ADR-0090/0094): bindings live
* only as `sys_position_permission_set` rows. A fresh deploy used to boot with
* ZERO bindings — every persona silently degraded to the `everyone` baseline
* until an admin hand-assigned all sets.
*
* This cannot be a declarative SEED: the seed loader runs before the security
* bootstrap creates the `sys_position` / `sys_permission_set` rows, so the name
* references cannot resolve and the required lookups fail validation. So we play
* the admin's part imperatively — inserting each missing binding idempotently
* (dedup by position+set pair, stable ids).
*
* Timing matters. `kernel:ready` handlers run SEQUENTIALLY, each awaited, in
* registration order (`kernel.ts` `trigger`). The showcase AppPlugin starts
* BEFORE the Security plugin, so an app hook on `kernel:ready` runs *before*
* the security bootstrap has created the position/set rows — the rows never
* appear from inside that hook. We therefore bind on **`kernel:listening`**,
* the phase the kernel fires only AFTER every `kernel:ready` handler has
* completed (`kernel.ts` Phase 4 / `lite-kernel.ts`), so the bootstrap rows are
* guaranteed present.
*
* `everyone → showcase_member_default` IS bound here. The security plugin only
* auto-binds an app's `isDefault` set to `everyone` when that set 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. Binding it here keeps the demo's baseline working out
* of the box, idempotently and alongside the suggestion.
*/

const BINDINGS: ReadonlyArray<readonly [position: string, permissionSet: string]> = [
['everyone', 'showcase_member_default'],
['contributor', 'showcase_contributor'],
['manager', 'showcase_manager'],
['exec', 'showcase_executive'],
['auditor', 'showcase_auditor'],
['ops', 'showcase_ops'],
['field_ops_delegate', 'showcase_field_ops_delegate'],
['client_portal_user', 'showcase_guest_portal'],
];

const SYS = { isSystem: true } as const;

interface BindHostContext {
ql: {
find: (object: string, query: unknown, options?: unknown) => Promise<unknown>;
insert: (object: string, data: Record<string, unknown>, options?: unknown) => Promise<unknown>;
};
logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void };
hook?: (event: string, handler: () => Promise<void> | void) => void;
}

/** Find one row by `name`, passing the system context the way the engine's own
* read path expects it (merged from `query.context`; see objectql `find`). */
async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> {
try {
const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as
| Array<{ id?: string }>
| { records?: Array<{ id?: string }> };
if (Array.isArray(rows)) return rows[0];
return rows?.records?.[0];
} catch (err) {
ctx.logger?.warn?.('[showcase] position binding lookup failed', {
object,
name,
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
}

export function registerShowcasePositionBindings(ctx: BindHostContext): void {
const run = async (): Promise<void> => {
let created = 0;
for (const [positionName, setName] of BINDINGS) {
const position = await findOneByName(ctx, 'sys_position', positionName);
const set = await findOneByName(ctx, 'sys_permission_set', setName);
if (!position?.id || !set?.id) {
ctx.logger?.warn?.('[showcase] position binding skipped (row missing)', { position: positionName, set: setName });
continue;
}
const existing = (await ctx.ql.find(
'sys_position_permission_set',
{ where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS },
)) as unknown;
const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0];
if (hit) continue;
try {
await ctx.ql.insert(
'sys_position_permission_set',
{ id: `ppsb_showcase_${positionName}`, position_id: position.id, permission_set_id: set.id },
{ context: SYS },
);
created += 1;
} catch (err) {
ctx.logger?.warn?.('[showcase] position binding insert failed', {
position: positionName,
set: setName,
error: err instanceof Error ? err.message : String(err),
});
}
}
ctx.logger?.info?.('[showcase] position bindings ensured', { created, total: BINDINGS.length });
};

// Bind on `kernel:listening` — the phase that fires only after every
// `kernel:ready` handler (incl. the security bootstrap that seeds the
// position/set rows) has completed. Fall back to a deferred immediate run
// if the host context somehow omits the hook registrar.
if (typeof ctx.hook === 'function') {
ctx.hook('kernel:listening', run);
} else {
setTimeout(() => void run(), 0);
}
}
Loading
Loading