diff --git a/.changeset/adr-0057-d10-citation-attributive.md b/.changeset/adr-0057-d10-citation-attributive.md new file mode 100644 index 0000000000..328c7fad1b --- /dev/null +++ b/.changeset/adr-0057-d10-citation-attributive.md @@ -0,0 +1,19 @@ +--- +"@objectstack/lint": patch +--- + +docs(lint): the `readonlyWhen` field-rule diagnostic no longer cites `ADR-0057 D10` (#9255) + +The author-visible consequence text for a faulting `readonlyWhen` predicate said +"Per ADR-0057 D10 the server is the one that decides". The rule it states is +correct and unchanged — the server locks the field while the form still renders +it editable — but the citation does not resolve: `D10` of the ERP-authorization +`ADR-0057` decides Setup-nav capability surfacing, and the other `ADR-0057` +(system data lifecycle) carries no D-numbered decisions at all. An author who +followed the anchor landed on an unrelated decision and had no way to tell +whether the code or their search was wrong. + +The diagnostic now states the rule on its own authority, which is where it +always rested. No behaviour, no message semantics and no rule changed — only +the traceability claim. Recording the rule as an actual decision is tracked +separately in #9628. diff --git a/docs/qa/platform-checklist/RUNNER.md b/docs/qa/platform-checklist/RUNNER.md index 40b1a9cba5..74ab25e92f 100644 --- a/docs/qa/platform-checklist/RUNNER.md +++ b/docs/qa/platform-checklist/RUNNER.md @@ -74,8 +74,11 @@ test-run output the clause's `evidence` field names. this" was the actual bug. 4. **Both sides of every gate.** For any permission/visibility/feature gate, verify presence for the entitled persona AND absence (or server-side rejection) for the - unentitled one. UI absence alone is a client courtesy; the server is the authority - (ADR-0057 D10) — where feasible, prove denial with a direct forged request. + unentitled one. UI absence alone is a client courtesy; the server is the authority. + (That rule is cited across the framework as `ADR-0057 D10`; treat it as an + attribution — D10 itself decides Setup-nav capability surfacing, and recording the + general rule is tracked in #9628. The rule is not in doubt, only its anchor.) + Where feasible, prove denial with a direct forged request. 5. **Severe findings are hypotheses.** "The whole surface is unreachable" gets disproven-or-confirmed via screenshot + the server's own metadata before it is written down (the golden rule of the dogfood skill). diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 8a2091351d..64b29f2468 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -1143,8 +1143,9 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { * whose trigger IS the unbound-root case) and * `stripReadonlyWhenFields` deletes the value from the * payload; client `fallback: false` ⇒ editable. The two ends - * fault in OPPOSITE directions and ADR-0057 D10 gives it to - * the server. ⇒ the old sentence was BACKWARDS here. + * fault in OPPOSITE directions and the "server enforces, + * client is courtesy" rule (cited as ADR-0057 D10; an + * attribution, #9628) gives it to the server. ⇒ the old sentence was BACKWARDS here. * requiredWhen server logs the unbound root and `continue`s (#4977 did not * copy the carve-out); client `fallback: false`. Both ends * fail open and neither is about visibility. ⇒ the old @@ -1183,7 +1184,9 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { }); it('`readonlyWhen` — names the client/server disagreement, not just the server verdict', () => { - // ADR-0057 D10: the form renders the field editable (`fallback: false`) + // Server enforces, client is courtesy (cited as ADR-0057 D10, an + // attribution — #9628): the form renders the field editable + // (`fallback: false`) // while the server locks it. An author who only reads "LOCKED" cannot // reconcile that with the editable input in front of them. const m = messageFor('readonlyWhen'); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 579af6f158..98bb6942d0 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -699,8 +699,10 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { * — and `stripReadonlyWhenFields` then DELETES the field from the incoming * payload and lets the rest of the write through. Client: * `resolveFieldRuleState` passes `fallback: false`, so the form renders the - * field editable. ADR-0057 D10 ("server enforces, client is courtesy") - * resolves the disagreement: the author edits the field, the save reports + * field editable. The standing "server enforces, client is courtesy" rule + * — cited in this repo as ADR-0057 D10, an attribution rather than a + * resolvable anchor (#9628) — resolves the disagreement: the author edits + * the field, the save reports * success, and the value silently never lands. The old sentence told this * author the field would be VISIBLE TO EVERYONE — the opposite failure, and * the opposite troubleshooting direction. @@ -763,7 +765,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { 'the predicate faults — and the two ends fault in OPPOSITE directions. The server treats ' + 'the field as LOCKED (`isReadonlyWhenLocked` will not waive a declared lock it could not ' + 'evaluate, #4889) and drops your value from the payload, while the form still renders the ' + - 'field editable (`fallback: false`). Per ADR-0057 D10 the server is the one that decides: ' + + 'field editable (`fallback: false`). The server is the one that decides: ' + 'the field looks writable, the save reports success, and the value silently never lands', requiredWhen: 'the predicate faults and the requirement is never enforced anywhere — the server logs it ' + diff --git a/packages/objectql/src/engine-readonly-when-parent.test.ts b/packages/objectql/src/engine-readonly-when-parent.test.ts index 80d8042e64..2dec8e4248 100644 --- a/packages/objectql/src/engine-readonly-when-parent.test.ts +++ b/packages/objectql/src/engine-readonly-when-parent.test.ts @@ -7,7 +7,8 @@ // Paid, its lines are frozen". It was enforced only in the client grid: the // server-side strip bound `record` and `previous` and nothing else, so every // `parent.*` predicate faulted, took the fail-OPEN branch, and the write landed -// with a 200 while the UI still drew the cell locked. ADR-0057 D10 puts +// with a 200 while the UI still drew the cell locked. The rule this repo cites +// as ADR-0057 D10 (an attribution, not a resolvable anchor — #9628) puts // enforcement on the SERVER and makes the client courtesy; this suite pins that // direction end-to-end through the real engine + a real driver, not through the // strip function in isolation (PD #10: a `case` label is not enforcement — diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 43a344ac72..513ae80470 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4970,8 +4970,9 @@ export class ObjectQL implements IObjectQLEngine { * `readonlyWhen` reads, or `null` when this write cannot resolve one. * * `readonlyWhen: parent.status == 'paid'` is a documented **server** - * guarantee (ADR-0057 D10 puts enforcement here; the client grid is - * courtesy), but the strip is a pure function over the payload and the prior + * guarantee (the rule this repo cites as ADR-0057 D10 puts enforcement here; + * the client grid is courtesy — an attribution, not a resolvable anchor, + * #9628), but the strip is a pure function over the payload and the prior * row — it has no driver and cannot fetch a header. So the engine resolves it * and passes it in. * diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index ed67ae9479..6cc42e03e8 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -112,7 +112,11 @@ * line, recorded as an addendum on that ADR alongside the same narrowing #4649 * and #4775 made at the two neighbouring write gates. Do not widen it back on * the grounds that it reads inconsistent with D5's table — the table is what - * was amended, and ADR-0057 D10 (server enforces, client is courtesy) is why. + * was amended, and the standing "server enforces, client is courtesy" rule is + * why. This repo cites that rule as ADR-0057 D10 — an ATTRIBUTION, not a + * resolvable anchor: D10 decides Setup-nav capability surfacing, and no ADR + * records the general rule yet (#9628). The rule stands on its own; what is + * missing is the decision record, not the discipline. * * ## `requiredWhen`: the SCOPE is bound, the SEMANTICS are not changed (#4977) * @@ -657,9 +661,10 @@ export function stripReadonlyWhenFields( * is unlocked — we simply could not ask. Waving it through inverts the * guarantee: a field the author declared locked is written, the API answers * 200, and the client grid still draws the cell as read-only, so the UI and - * the database disagree with nobody told. ADR-0057 D10 puts enforcement on - * the server; a lock that fails open leaves enforcement in the courtesy - * layer. So an unbound root resolves to LOCKED — conservative toward the + * the database disagree with nobody told. The rule this repo cites as + * ADR-0057 D10 puts enforcement on the server; a lock that fails open leaves + * enforcement in the courtesy layer. (Attribution, not a resolvable anchor — + * see the module header and #9628.) So an unbound root resolves to LOCKED — conservative toward the * author's declared intent, and the direction #4649 (validation predicates) * and #4775 (hook conditions) already took for their own unevaluable case. * diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 77dd09e3ad..606398f253 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -237,7 +237,8 @@ function allPathsMounted(rawApp: any, paths: readonly string[]): boolean { * (`admin_full_access` `'*': {modifyAllRecords}`) who ALSO holds * `organization_admin` (which denies writes on identity tables): the client * would see `sys_user.allowEdit:false` and disable a form the server accepts - * (verified: `PATCH /data/sys_user {name}` → 200). ADR-0057 D10 makes the + * (verified: `PATCH /data/sys_user {name}` → 200). The rule this repo cites as + * ADR-0057 D10 — an attribution, not a resolvable anchor (#9628) — makes the * server the authoritative gate; the client must mirror it, never diverge. * * The super-user grant covers private/managed objects on the server, so folding @@ -864,7 +865,8 @@ export function registerCurrentUserEndpoints( } // Make the client's per-object FLS reflect the server's ACTUAL // effective enforcement = permission-set grant ∩ identity write - // guard (ADR-0057 D10). (1) Fold the `'*'` super-user grant into + // guard (server enforces, client is courtesy — cited as ADR-0057 + // D10, an attribution, #9628). (1) Fold the `'*'` super-user grant into // every object so an admin's wildcard is not shadowed by another // set's explicit deny; (2) re-clamp `better-auth` managed objects // by their write affordance, since the guard (ADR-0092 D2) blocks diff --git a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts index c99952a38e..1bfc53e31a 100644 --- a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts +++ b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts @@ -4,7 +4,9 @@ import { describe, it, expect } from 'vitest'; import { foldWildcardSuperUser, clampManagedObjectWrites, type ManagedSchemaLike } from './current-user-endpoints.js'; /** - * ADR-0057 D10 / ADR-0092 D5 — the `/me/permissions` per-object FLS map must + * Server enforces, client is courtesy (cited as ADR-0057 D10 — an attribution, + * not a resolvable anchor, #9628) / ADR-0092 D5 — the `/me/permissions` + * per-object FLS map must * mirror the server's actual enforcement, which grants writes via a `'*'` * modifyAll super-user bypass regardless of another set's explicit per-object * deny (most-permissive merge, no deny-wins). diff --git a/packages/qa/dogfood/test/showcase-readonly-when-parent.dogfood.test.ts b/packages/qa/dogfood/test/showcase-readonly-when-parent.dogfood.test.ts index f2dcfd878f..8c0b007aff 100644 --- a/packages/qa/dogfood/test/showcase-readonly-when-parent.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-readonly-when-parent.dogfood.test.ts @@ -11,8 +11,10 @@ // `previous` and had no `parent`, so every one of those predicates faulted, the // fail-OPEN branch let the write through, and a single PATCH rewrote the // quantity and unit price of a settled invoice's line — HTTP 200, value -// persisted, while the grid still drew the cell locked. ADR-0057 D10 makes the -// SERVER the enforcement point and the client courtesy; here it was inverted. +// persisted, while the grid still drew the cell locked. The rule this repo +// cites as ADR-0057 D10 (an attribution, not a resolvable anchor — #9628) +// makes the SERVER the enforcement point and the client courtesy; here it was +// inverted. // // This is the issue's own repro, run against the shipped metadata rather than a // hand-built fixture: the unit + engine suites in `@objectstack/objectql` pin diff --git a/scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json b/scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json index c6f117d558..0dce84888a 100644 --- a/scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json +++ b/scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json @@ -4,5 +4,5 @@ "ADR-0057", "ADR-0058" ], - "invariant": "A declared field lock is the SERVER's to enforce (ADR-0057 D10 — the client grid is courtesy). A `readonlyWhen` whose predicate names a scope root the write path could not bind resolves to LOCKED, not to \"not locked\": \"could not check\" must never read as \"allowed\" on a field the author declared frozen. This narrows ADR-0058 D5's fail-soft tier deliberately and only for that case — a merely BROKEN predicate (undeclared key, null overload, parse fault, throw) still fails open, and requiredWhen / option visibleWhen are untouched." + "invariant": "A declared field lock is the SERVER's to enforce — the client grid is courtesy. This repo cites that rule as ADR-0057 D10; read it as an ATTRIBUTION, not as a resolvable anchor. D10 of `0057-erp-authorization-core-business-units-and-scope-depth.md` decides Setup-nav capability surfacing, and only its PS-2 implementation note carries the server-enforcement half — scoped to nav. The other ADR-0057 (system data lifecycle) has no D-numbered decisions at all. The rule itself is true and unchanged here; recording it as a decision is #9628. A `readonlyWhen` whose predicate names a scope root the write path could not bind resolves to LOCKED, not to \"not locked\": \"could not check\" must never read as \"allowed\" on a field the author declared frozen. This narrows ADR-0058 D5's fail-soft tier deliberately and only for that case — a merely BROKEN predicate (undeclared key, null overload, parse fault, throw) still fails open, and requiredWhen / option visibleWhen are untouched." }