|
| 1 | +# ADR-0049: Spec must not declare security properties the runtime does not enforce (enforce-or-remove gate) |
| 2 | + |
| 3 | +**Status**: Proposed (2026-06-15) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay), [ADR-0010](./0010-metadata-protection-model.md) (package provenance), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (authoring lifecycle) |
| 6 | +**Consumers**: `@objectstack/spec` (security/identity schemas), `@objectstack/plugin-security` (`PermissionEvaluator`, `SecurityPlugin`), spec authors, the metadata-property liveness audit follow-ups (#1878 P0 cluster). |
| 7 | +**Surfaced by**: the metadata property liveness audit (#1878, `docs/audits/`) — which found that **roughly half of all spec properties are dead**, and that a cluster of *security* properties is **parsed but unenforced**. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## TL;DR |
| 12 | + |
| 13 | +A protocol-level audit cross-referenced every spec property against its actual |
| 14 | +runtime consumers. The most serious finding is a cluster of **security |
| 15 | +properties that imply an access-control boundary but enforce nothing**: |
| 16 | +`PolicySchema` (100% dead — password/session/MFA/IP/audit), permission |
| 17 | +lifecycle bits (`allowTransfer`/`allowRestore`/`allowPurge`), `agent` |
| 18 | +access-control, flow `runAs`, object `apiEnabled`/`apiMethods`, action |
| 19 | +`disabled`, role `parent`, and `SharingRuleSchema`. |
| 20 | + |
| 21 | +A security property that parses but does nothing is **worse than absent**: it |
| 22 | +produces a *false sense of compliance*. An admin who sets `allowPurge: false` |
| 23 | +or authors a strict password policy believes a boundary exists where none does. |
| 24 | + |
| 25 | +**Decision.** A spec property that names a security/access-control boundary |
| 26 | +**must be in exactly one of three states**: |
| 27 | + |
| 28 | +1. **Enforced** — a runtime consumer reads it and changes a decision (`file:line`). |
| 29 | +2. **`experimental`** — explicitly marked and documented as *not yet enforced*, |
| 30 | + so authoring it is a known no-op (roadmapped, not a promise). |
| 31 | +3. **Absent** — removed from the spec. |
| 32 | + |
| 33 | +Shipping a security property in a fourth state — *parsed, unmarked, unenforced* |
| 34 | +— is prohibited. This is the **enforce-or-remove gate**. |
| 35 | + |
| 36 | +A second, roadmap-independent defect compounds the first: `PermissionEvaluator` |
| 37 | +**fails open** for operations it doesn't recognise |
| 38 | +(`permission-evaluator.ts:35`, `if (!permKey) return true`). Any future |
| 39 | +destructive operation added without registering it in `OPERATION_TO_PERMISSION` |
| 40 | +is silently ungated. The evaluator must **fail closed** for the destructive |
| 41 | +operation class. |
| 42 | + |
| 43 | +--- |
| 44 | + |
| 45 | +## Context |
| 46 | + |
| 47 | +- Evidence: `docs/audits/2026-06-security-identity-property-liveness.md` and the |
| 48 | + cross-type synthesis in `docs/audits/README.md` (cluster #1). |
| 49 | +- The CRUD path *is* enforced: `SecurityPlugin` (`security-plugin.ts:326`) |
| 50 | + resolves permission sets and calls `PermissionEvaluator.checkObjectPermission`, |
| 51 | + which maps the ObjectQL operation to an `ObjectPermission` key via |
| 52 | + `OPERATION_TO_PERMISSION` (`permission-evaluator.ts:8-16`). |
| 53 | +- That map covers only `find/findOne/count/aggregate/insert/update/delete`. The |
| 54 | + three destructive permission bits in the spec |
| 55 | + (`permission.zod.ts:28-30` — `allowTransfer`/`allowRestore`/`allowPurge`) |
| 56 | + have **no operation pointing at them**, and the operations they describe |
| 57 | + (`transfer`/`restore`/`purge`) **do not yet exist** as ObjectQL operations. |
| 58 | + So the bits are dangling, and the `if (!permKey) return true` default means |
| 59 | + that *if* such an operation were added without a map entry, it would be |
| 60 | + allowed for everyone. |
| 61 | + |
| 62 | +## Decision — staged by the platform's current (pre-MVP) phase |
| 63 | + |
| 64 | +The audit's instinct was "enforce every unenforced security prop." At the |
| 65 | +current milestone that is the **wrong default**: building enforcement for |
| 66 | +features that do not exist yet is speculative. The real, shippable liability is |
| 67 | +the *false promise*, not the missing feature. So we split the P0 cluster by |
| 68 | +**whether the feature already exists**: |
| 69 | + |
| 70 | +| Situation | Items | Phase action | |
| 71 | +|---|---|---| |
| 72 | +| **Feature does not exist; spec bit is a dangling promise** | `PolicySchema` (#1882), permission lifecycle bits (#1883), `SharingRuleSchema` spec form (#1887), flow `runAs` (#1888) | **Remove or mark `experimental`** now. Re-introduce *with* the feature + enforcement at M2/production. | |
| 73 | +| **Feature is live; the gate is missing or bypassed** | agent access-control (#1884), object `apiEnabled`/`apiMethods` (#1889), action `disabled` CEL (#1885) | **Enforce** now — these are real, exploitable gaps and the fix is a localized check at the route/renderer. | |
| 74 | + |
| 75 | +Plus one **no-regret correctness fix**, independent of roadmap: |
| 76 | + |
| 77 | +- `PermissionEvaluator` fails **closed** for the destructive operation class: |
| 78 | + introduce an explicit set of sensitive/destructive operations; an unrecognised |
| 79 | + operation in that class is **denied**, not allowed. (Non-destructive unknown |
| 80 | + operations may retain default-allow to avoid breaking custom read-side ops.) |
| 81 | + |
| 82 | +### `experimental` convention for the "mark, don't remove" path |
| 83 | + |
| 84 | +For a roadmapped property we keep but cannot yet enforce, annotate it so the |
| 85 | +no-op is explicit to authors and tooling, rather than silently parsing: |
| 86 | + |
| 87 | +- prefix the Zod `.describe()` with **`[EXPERIMENTAL — not enforced]`**, and |
| 88 | +- where the surrounding schema already carries a status/stability enum (e.g. |
| 89 | + `model-registry.zod.ts`, `plugin-capability.zod.ts`), prefer that enum. |
| 90 | + |
| 91 | +Removal is preferred over marking when there is no committed roadmap for the |
| 92 | +property — a smaller spec surface is the stronger default pre-MVP. |
| 93 | + |
| 94 | +## Consequences |
| 95 | + |
| 96 | +- **Positive.** No spec property silently misleads an admin about a security |
| 97 | + boundary. The evaluator can no longer be made to fail open by adding a |
| 98 | + destructive operation. The P0 cluster splits into a cheap no-regret PR |
| 99 | + (evaluator fail-closed + mark/remove dangling bits) and a small enforcement |
| 100 | + PR (live-but-ungated features), deferring the heavy work (policy registration, |
| 101 | + sharing-rule engine reconciliation) to when the feature lands. |
| 102 | +- **Negative / cost.** Removing or `experimental`-tagging spec bits is a |
| 103 | + spec-surface change; seeds/fixtures that author the removed bits must be |
| 104 | + updated (low risk pre-MVP). The fail-closed change requires enumerating the |
| 105 | + destructive operation class so legitimate custom operations are not denied. |
| 106 | +- **Follow-up.** This ADR is the umbrella decision for the #1878 P0 cluster; |
| 107 | + each sub-issue records its enforce/experimental/remove disposition against the |
| 108 | + table above. |
| 109 | + |
| 110 | +## Non-goals |
| 111 | + |
| 112 | +- Building the transfer/restore/purge, policy-enforcement, or sharing-rule |
| 113 | + engines themselves — those are feature work for M2/production, tracked by |
| 114 | + their respective issues. |
| 115 | +- The P1 (ADR-0021 analytics migration) and P2 (spec hygiene) clusters of |
| 116 | + #1878 — non-security, governed separately. |
0 commit comments