diff --git a/.changeset/18582-sharing-rule-liveness-ledger.md b/.changeset/18582-sharing-rule-liveness-ledger.md new file mode 100644 index 00000000000..215bba4d0bf --- /dev/null +++ b/.changeset/18582-sharing-rule-liveness-ledger.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": patch +--- + +`liveness/sharing_rule.json` — the sharing-rule authoring surface is now a governed liveness type: every authorable key of `SharingRuleSchema` carries a status, the evidence that settles it and the producer that populates it (part of #18582). + +The ledgers ship inside this package (`files[]` includes `liveness`), so this is a new file in the tarball and two changed ones — `liveness/README.md`'s index row and the generated `liveness/state-counts.md`. Nothing else moves: no schema accepts or refuses anything it did not before, no export changes, and no CLI author warning is added (no entry is marked `authorWarn`). + +- **Why it was ungoverned.** `sharing_rule` is bound in `UNREGISTERED_KIND_SCHEMAS`, which `listMetadataTypeSchemaTypes()` deliberately does not enumerate, so it sat in **neither** `GOVERNED` **nor** `PENDING_GOVERNANCE` and produced no row in any of the gate's lists while the report read complete. Widening the governance denominator to the authorable set made it visible as a declared debt; this pays that debt. `connector` and `analytics_cube` are still owed. +- **Every row cites a producer, because the authoring shape is not the enforced shape.** ADR-0057 D6 makes the `sys_sharing_rule` row canonical — `object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level` — and `bootstrapDeclaredSharingRules` translates each authored key into it at boot. Nothing re-parses `SharingRuleSchema` at enforcement time, so a consumer pointer alone would prove only that a column is read, never that the authored value reaches it. +- **Nine keys are `live`; one is `planned`.** `type` is the `SharingRuleType` discriminator: one member, `criteria`, whose only reader in this repo is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. It is deliberately **not** `dead` and therefore not an enforce-or-remove candidate — the key is required, so removing it would break every authored rule to delete nothing, and the schema keeps it as the discriminant for a future enforced rule type. +- **`sharedWith` is drilled**, so the two recipient keys carry their own verdicts and the change adds no row to the undrilled-container baseline. + +For an author, the practical read: `name`, `object`, `active`, `accessLevel`, `condition` and both `sharedWith` keys change what the runtime grants; `label` and `description` are display-shaped and are shown in Setup; `type` has exactly one legal value and, today, no dispatch behind it. diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index 3d44e7a6a24..dc49f828c00 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -256,6 +256,13 @@ "platform-core.seed-mode-matrix" ] }, + "sharing_rule": { + "items": [ + "access-security.sharing-rules-widen", + "access-security.sharing-rule-authoring-ui", + "access-security.record-share-grant-revoke" + ] + }, "skill": { "items": [ "ai.agent-tool-skill-metadata-roundtrip", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index d3691cad368..f133b06265a 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -816,7 +816,7 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type RecordDetailView had been gating the History tab on it the whole time (#2707). 4. Add the type to `GOVERNED`; confirm the gate is green. -## Current state — 36 governed types (complete registry coverage) +## Current state — 37 governed types (complete registry coverage) > **This heading is now checked** (#7257). `check:liveness` reconciles the table > against `GOVERNED` in both directions — a governed type with no row fails, a row @@ -937,6 +937,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | batch_endpoints | seeded 2026-09-02 (#14369) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 2 = `operations.upsertMany` and `defaultAtomic`. `upsertMany` is `endpoints.schema`'s twin — a switch declared for a route that was never built (`this.protocol` carries `createManyData` / `updateManyData` / `deleteManyData` and no upsert counterpart), so `false` disables nothing. `defaultAtomic` promises a transaction default that no batch handler consults. Live 5 = `maxBatchSize` (load-bearing since #11984 gave it a real parse — before that a configured `0` was the live cap, because `0` is not nullish), `enableBatchEndpoint`, and the three `operations.*` switches that do gate a mount **#14691 RETIRED both (2026-09-03, ADR-0049)**: `operations.upsertMany` and `defaultAtomic` are `retiredKey()` tombstones, rows kept `dead` with a REMOVED note. `defaultAtomic` is the family's worked enforce-or-remove call: the per-request `options.atomic` (ADR-0119 D4, opt-in) IS the contract, and a server default that flipped it silently is the move that ADR refused, so the key was removed rather than wired; upsert lives on as an operation type of the generic batch endpoint. `evidenceScope` widened to `cross-repo` (#14796) | | route_generation | seeded 2026-09-02 (#14369) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 6 = every key it has, and that is the finding: `routes` is parsed, defaulted and normalized into `this.config.routes`, and nothing ever reads it back. `excludeObjects: ['sys_log']` excludes nothing, `nameTransform: 'plural'` still mounts every route under the raw object name, and the per-object `overrides` record (drilled to `enabled` / `basePath` / `operations`) turns nothing on or off. ⚠️ The `overrides` hits in `packages/rest/src` are a REQUEST BODY and a test builder — different keys with the same name. This is the one member of the family with a customer-visible limb: `RestServerConfigSchema`'s own `@example` advertises `routes: { excludeObjects: ['system_log'] }`, so the published prose promises a capability the runtime does not deliver (Prime Directive #10). Fixing that example belongs to whichever enforce-or-remove limb the key lands on — `routes.*` reads as designed-but-never-wired, so enforcing it is real work in route generation that changes the mounted surface, and no dev agent decides that **#14691 RETIRED all six (2026-09-03, ADR-0049)**: every key is now a `retiredKey()` tombstone and the sub-object is tombstones-only; the rows stay `dead` with a REMOVED note (non-strict schema) and the three `overrides.*` child rows collapse into the one `overrides` row. Triage held `overrides` open as an ENFORCE candidate; the measurement closed it as REMOVE because the capability already exists at its proper seat — per-object exposure is the object's own `enable.apiEnabled` / `enable.apiMethods`, enforced by rest-server.ts#enforceApiAccess (404 / 405) — and `basePath` / `nameTransform` would contradict the one deployment-wide data base and Prime Directive #6 (the object name IS the REST path segment). The `@example` limb is corrected in the same change. `evidenceScope` widened to `cross-repo` (#14796) | | realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`) are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | +| sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time @@ -961,3 +962,5 @@ gate's lists while the report read complete. They are now declared debts with a reason and an issue number apiece, which is the state this ratchet exists to produce; the direction of travel is out of that map and into `GOVERNED`, exactly as it was for the nine. ⛔ Their presence is not a licence to leave them there. +**One of the three is paid**: `sharing_rule` is governed as of #18582, and the map +now holds `connector` and `analytics_cube`. diff --git a/packages/spec/liveness/sharing_rule.json b/packages/spec/liveness/sharing_rule.json new file mode 100644 index 00000000000..3586ad84bce --- /dev/null +++ b/packages/spec/liveness/sharing_rule.json @@ -0,0 +1,82 @@ +{ + "type": "sharing_rule", + "_note": "SharingRuleSchema (packages/spec/src/security/sharing.zod.ts) — `SharingRuleSchema = CriteriaSharingRuleSchema`, the one authorable rule form. Seeded 2026-09-17 (#18582): the SECOND of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator from the registered kinds to `authorableTypes()`; `connector` and `analytics_cube` are still owed on that card. NOT a registered metadata KIND — it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches this walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so the ledger governs it while `listMetadataTypeSchemaTypes()` still does not enumerate it. THE SHAPE FACT THAT DECIDES EVERY ROW BELOW: the authoring shape is not the enforced shape. ADR-0057 D6 makes the RUNTIME row canonical (`sys_sharing_rule`: `object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` TRANSLATES each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time. So every consumer cited below reads a COLUMN that a producer had to populate, and every row therefore carries a `producer` (#4837) naming the threading site: a consumer citation alone would be the `seed.env` shape, where the mechanism was right and nobody supplied the input. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f381): `registerBuiltinPreviews()` in packages/app-shell/src/views/metadata-admin/previews/index.ts registers twenty types and `sharing_rule` is NOT one of them — this type has no registered metadata-admin preview. Recorded rather than skipped, because \"the type has no registered preview\" is the sentence a later sweep needs. What objectui DOES consume is the whole SHAPE: `clientValidation.ts`'s `AUTHOR_SHAPE_ONLY_TYPES` gates the metadata-admin CREATE door on `SharingRuleSchema` itself (the EDIT door is deliberately not gated — a served body carries the `_diagnostics` read decoration this `.strict()` schema rejects), so an authored rule that fails this schema is refused before it is written. DECOY, do not cite it as a consumer: objectui's own `SharingRuleConfig` (objectui packages/types/src/permissions.ts) is a DIFFERENT shape — `{ type: 'role'|'user'|'group'|'public', entity, actions, filter }` — re-exported twice and read by nothing. It matches this type by name only. RUNTIME PROOF THAT ALREADY EXISTS, unbound: packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts authors `sharingRules[]` on the showcase stack and asserts the seeded row's `object_name`, `recipient_type`, `recipient_id` and translated `criteria_json` — i.e. it exercises `name`/`object`/`sharedWith.type`/`sharedWith.value`/`condition` end to end. It is registered in ../scripts/liveness/proof-registry.mts as `declarative-rbac-seeding` with `bound: false`, whose `blockedReason` reads \"not on a per-type authorable property\" — a premise this seeding falsifies. ⛔ No `proof` is claimed on any row here: binding a high-risk class is a separate ADR-0054 §3 act, one class at a time, and it is filed rather than slipped in.", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#defineRule — the upsert key: it looks the existing row up by `(name, organization_id)` and writes `name` onto `sys_sharing_rule`, so the authored name decides whether a boot creates a second rule or updates the one already standing; packages/plugins/plugin-sharing/src/sharing-rule-service.ts#getRule resolves the REST by-id-or-name routes against the same column, and every `sys_record_share` row the rule materialises carries `reason: rule:{name}`.", + "producer": "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules — `if (!r?.name || !r?.object) { skipped += 1; continue; }` drops a declared rule that has no name before anything else runs, and passes the authored value straight into `defineRule` as the key; nothing else supplies one.", + "note": "Identity, not display: `titleFormat` is `{label}`, and `name` is the object's `displayNameField`/`nameField` plus the first of its `highlightFields`." + }, + "label": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidenceScope": "cross-repo", + "evidence": "packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts#SysSharingRule — `titleFormat: '{label}'` makes the authored label the record title of every `sys_sharing_rule` row, and `label` is a column of all four declared listViews (`active`, `inactive`, `by_object`, `all_rules`); objectui @dda8f381 packages/components/src/renderers/layout/containers.tsx interpolates `objectSchema.titleFormat` for the rendered title, so the authored string is what an admin reads in Setup.", + "producer": "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules — `label: r.label ?? r.name` threads the authored label into `defineRule`, which refuses an empty one outright (`VALIDATION_FAILED: label is required`) and stores it; without that thread the column would hold the rule NAME for every declared rule, which is exactly what an unauthored `label` still produces.", + "note": "Display-shaped, so the #7131 split settles it: being shown to a human IS the whole of the claimed effect, and there is no second layer where a \"real\" consumer would live. The read point is the platform object's own record surface, NOT a metadata-admin preview — this type has none (enumerated in the file note above)." + }, + "description": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidenceScope": "cross-repo", + "evidence": "packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts#SysSharingRule — declared as `Field.textarea({ label: 'Description', group: 'Identity' })`, so the stored value is rendered on the `sys_sharing_rule` record detail/edit surface that Setup's generic object views build from the field groups (objectui @dda8f381); it is deliberately absent from `highlightFields` and from every listView's columns.", + "producer": "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules — `description: r.description ?? undefined` threads the authored note into `defineRule`, which stores `input.description ?? null`.", + "note": "Display-shaped and deliberately kept, the `position.description` precedent: administrative notes, nothing gates on them. Not `authorWarn`ed — an author is not misled by a description that only describes." + }, + "object": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#matchRecord — `this.engine.find(rule.object_name, { filter, ... })`: the authored object is what the criteria query runs against, so it decides which table's records the rule can grant at all; packages/plugins/plugin-sharing/src/rule-hooks.ts#bindRuleHooks binds the per-record recompute hooks on exactly this object (`objects.add(r.object_name)`), so it also decides which writes trigger re-materialisation; packages/lint/src/validate-sharing-rule-enforceability.ts crosses it against `stack.objects` at authoring time to read the anchor's OWD and refuses a rule whose object could never yield a `sys_record_share` grant.", + "producer": "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules — skips a declared rule with no `object` and passes it to `defineRule`, which writes `object_name: input.object` onto the row every consumer above reads.", + "note": "Exercised end to end by the unbound `declarative-rbac-seeding` dogfood proof (see the file note): a declared rule on `showcase_inquiry` is asserted to land with `object_name === 'showcase_inquiry'`." + }, + "active": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#evaluateRule — `if (!rule.active)` purges the rule's materialised grants and returns a pass that created none; packages/plugins/plugin-sharing/src/sharing-rule-service.ts#evaluateAllForRecord computes `rule.active ? await this.matchRecord(...) : null`, so an inactive rule's desired grant set is EMPTY and `reconcileForRecord` revokes what it had rather than skipping it (#4433); packages/plugins/plugin-sharing/src/rule-hooks.ts#bindRuleHooks skips an inactive rule when choosing which objects to bind (`if (r.active === false) continue`).", + "producer": "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules — `active: r.active !== false` threads the authored flag (absent = true, matching the schema default) into `defineRule`, which recomputes the same `input.active !== false` and stores it.", + "note": "`false` withdraws real access rather than merely not granting it — the revoke direction is the half #4433 had to add. An admin's `active: false` on a seeded rule also survives redeploys: `defineRule`'s seed-not-clobber never resurrects a row marked `customized`." + }, + "accessLevel": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#grantOrAbsorbOrganizationRefusal — `accessLevel: rule.access_level` is stamped onto every `sys_record_share` row the rule materialises; packages/plugins/plugin-sharing/src/sharing-service.ts#buildWriteFilter and packages/plugins/plugin-sharing/src/sharing-service.ts#canEdit then admit only `access_level: { $in: WRITE_ACCESS_LEVELS }`, so `edit` and only `edit` opens the write gate while `read` widens the read filter alone (packages/plugins/plugin-sharing/src/sharing-service.ts#buildReadFilter); packages/plugins/plugin-sharing/src/access-level.ts#WRITE_ACCESS_LEVELS is the one definition both gates share.", + "producer": "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules — `accessLevel: (r.accessLevel ?? 'read')` threads the authored level into `defineRule`, which runs it through `normalizeAccessLevel(input.accessLevel, 'read')` before storing it.", + "note": "Both members are enforced; the enum has no third. The retired `full` is the reason this row is worth a sentence: it was declared as \"Full Access (Transfer, Share, Delete)\" and was byte-equivalent to `edit` at all three gates, so it was removed from the authoring surface (#3865) rather than left as false compliance. `WRITE_ACCESS_LEVELS` stays wider than the authorable set on purpose, for rows persisted before the backfill." + }, + "sharedWith": { + "children": { + "type": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipient branches on the stored member and the branches differ in WIDTH: `user` resolves to the id itself, `team` to `TeamGraphService.expandUsers`, `business_unit` to exactly one unit's members, `unit_and_subordinates` to the business-unit SUBTREE walk, `position` to the position's holders with a deactivated-position check first; packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipientForRecord routes `field` — and only `field` — to a per-record expansion instead of the rule-wide one.", + "producer": "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#mapRecipientType maps the authored member onto the runtime `recipient_type` column those branches read; an unmappable value returns null and packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules SKIPS the rule with a warning rather than seeding it.", + "note": "The authorable enum is the authorable SUBSET of the runtime recipient contract: `queue` is reserved by the runtime and deliberately not authorable, and `group`/`guest` were removed (ADR-0078). `business_unit` vs `unit_and_subordinates` is the pair where the two widths are the contract — they shared an expansion call until #7807, differing only in their comments." + }, + "value": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipient — `rule.recipient_id` is the principal every rule-wide branch resolves (the user id, the team, the position, the business unit); packages/plugins/plugin-sharing/src/sharing-rule-service.ts#recipientsNamedOnRecord reads it as a COLUMN NAME for a `field` recipient — `usersNamedBy(row[rule.recipient_id])` on each matched record, fail-closed when the column names nobody; packages/plugins/plugin-sharing/src/sharing-rule-service.ts#matchRecord projects that same column beside `id` so the match and the recipients cannot be read from two versions of the row.", + "producer": "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules — `recipientId: String(r.sharedWith.value)`; a rule whose `sharedWith.value` is missing or empty is skipped and warned, never seeded.", + "note": "Two grammars behind one key, and the `field` one is refused at AUTHORING: the `superRefine` on the recipient shape in packages/spec/src/security/sharing.zod.ts rejects an empty name and a dotted path when `type: 'field'`, so `owner.manager_id` cannot be smuggled in as a graph walk spelled as a value. For every other member the value stays the opaque id it was." + } + } + }, + "type": { + "status": "planned", + "verifiedAt": "2026-09-17", + "evidenceScope": "cross-repo", + "evidence": "packages/spec/src/security/sharing.zod.ts#SharingRuleType declares exactly one member, `criteria`, and `CriteriaSharingRuleSchema` pins the key as `z.literal('criteria')` — so every value an author can write is the same value. The one reader of the key in this repo is packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules (`if (r.type === 'owner')`), which is DEFENSIVE against a stale pre-built package and is unreachable for anything this schema admits: `owner`-type rules left the authoring surface with ADR-0078.", + "note": "PLANNED, and the two things it is NOT are the point. Not `live`: nothing dispatches on it — the sole reader compares it against a value the schema rejects, which is Prime Directive #10's `case` label with no reachable call site. Not `dead`: the key is REQUIRED, so it is not a silent no-op an author can get wrong, and the schema's own header records the intent — \"Kept as the `SharingRuleType`-discriminated form so a future enforced rule type (e.g. membership-reactive owner-based) re-joins as a union member\". ⛔ Therefore NOT an ADR-0049 enforce-or-remove candidate: removing a required literal would break every authored rule to delete nothing. PRECEDENT, same shape and same verdict: `action.operation` — a one-member discriminator held `planned` until a runtime half actually dispatched on it, then flipped to `live` (#15080). Flip this row the day `SharingRuleType` has a second member and the seeder branches on it. CENSUS behind the negative, with a lit control rather than a bare grep: the population that reads a declared rule ITEM is the seeder, four `packages/lint` validators and objectui's create-door client validation (objectui @dda8f381); searching that population for `r.type` / `rule.type` returns the one defensive `'owner'` comparison above and nothing else, while the same search for `r.condition` / `rule.condition` returns hits in three of them." + }, + "condition": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#celToFilterOutcome lowers the authored CEL through the canonical `@objectstack/formula` `compileCelToFilter` into the runtime `criteria_json` FilterCondition; packages/plugins/plugin-sharing/src/sharing-rule-service.ts#matchRecord then uses that filter as the `find()` predicate that decides which records the rule grants at all. A condition the compiler cannot lower — or one that lowers to a match-all (packages/plugins/plugin-sharing/src/rule-criteria.ts#isMatchAllCriteria) — SKIPS the rule with the compiler's own `reason`/`detail`, never seeding a permissive rule (ADR-0049); packages/lint/src/validate-sharing-rule-enforceability.ts runs the seeder's exact compiler call at authoring time so an unenforceable condition is reported before boot.", + "producer": "packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts#bootstrapDeclaredSharingRules — `const outcome = celToFilterOutcome(r.condition)`, and only a non-null, non-match-all filter reaches `defineRule` as `criteria`.", + "note": "The empty state is the dangerous one and it is classified elsewhere by design: the empty-state registry records sharing `condition` as `closed` (#3896 — `criteria_json: null` used to evaluate as `find(object, { filter: {} })`, i.e. every record of the object granted to the recipient). This ledger asks whether the key does anything; that registry asks what its EMPTY value means, and both answers are needed here." + } + } +} diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 20850147af0..2657aa59118 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -63,4 +63,5 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **862** | **5** | **1** | **96** | **10** | **974** | +| `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | +| **total** | **878** | **5** | **1** | **96** | **11** | **991** | diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index b3c8aded6e3..baa1f1b9a47 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -263,7 +263,7 @@ const ledgerRoot = ledgerRootArg // Governed metadata types, rolled out highest-frequency / highest-risk first. // (`query` is not a metadata type — see SPEC_ONLY_SCHEMAS below.) -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'seed', 'translation', 'validation', 'api', 'capability', 'qa', 'manifest', 'crud_endpoints', 'metadata_endpoints', 'batch_endpoints', 'route_generation', 'realtime_subscription']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'seed', 'translation', 'validation', 'api', 'capability', 'qa', 'manifest', 'crud_endpoints', 'metadata_endpoints', 'batch_endpoints', 'route_generation', 'realtime_subscription', 'sharing_rule']; // Authorable metadata types that are NOT yet governed — the coverage ratchet. // @@ -298,8 +298,14 @@ const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'po // anywhere and the report read complete — a reader could not tell "nothing // ungoverned here" from "never looked". Widening the denominator (see // `authorableTypes()`) is what converts that silence into three declared debts. -// ⛔ These three rows are NOT a licence to leave them: the direction of travel -// is out of this map, exactly as it was for the nine. +// ⛔ These rows are NOT a licence to leave them: the direction of travel is out +// of this map, exactly as it was for the nine. +// +// [#18582] One of the three is PAID: `sharing_rule` moved into GOVERNED with +// packages/spec/liveness/sharing_rule.json. The two left are `connector` and +// `analytics_cube`, and the card that owes them is still open — the sentence +// above is the whole reason this note updates the count instead of leaving a +// stale "three" standing over a map of two. const PENDING_GOVERNANCE: Record = { connector: 'Authored via `stack.connectors[]` and `PUT /api/v1/meta/connector/:name` ' @@ -307,12 +313,6 @@ const PENDING_GOVERNANCE: Record = { + 'enrolled into this gate\'s denominator by #18133 — before which it was invisible ' + 'to the ratchet rather than covered by it. No ledger walked it yet: seed ' + 'packages/spec/liveness/connector.json (#18133).', - sharing_rule: - 'Authored via `stack.sharingRules[]` and `PUT /api/v1/meta/sharing_rule/:name` ' - + '(#6245). Security-shaped surface — a sharing rule grants read scope — which is ' - + 'the class the #4410/#4465/#4481 datasource keys came from, and enrolling it here ' - + 'is #18133. No ledger walked it yet: seed packages/spec/liveness/sharing_rule.json ' - + '(#18133).', analytics_cube: 'Authored via `stack.analyticsCubes[]` and `PUT /api/v1/meta/analytics_cube/:name` ' + '(#10194). Whether that authoring is live end-to-end is its own measurement '