Skip to content

Commit 0f8db52

Browse files
committed
test(security): pin static-readonly server enforcement end-to-end + fix its declared contract (#3003)
#3003 reproduced the pre-#2948 gap in production terms: approval/status/amount columns "protected" only by static `readonly: true` were forged with one direct same-session REST PATCH on a draft record, self-approving a 4-stage approval on the released 15.0.0 (RECORD_LOCKED only guards pending flows, and the strip that closes this — stripReadonlyFields, #2957 — landed after that release cut). The engine fix is already on main; what was still missing is everything that keeps the contract from silently regressing or being misread again: - dogfood proof (showcase-static-readonly.dogfood.test.ts, @proof: readonly-static-write): over real HTTP on the showcase app, a non-admin owner's PATCH forging `lead_score` returns 200 with the persisted value kept while sibling editable fields land, an all-forged payload is a no-op, and INSERT may still seed the column (the documented readonlyWhen-symmetric exemption). - authz conformance row `readonly-static-write` (+ HIGH_RISK) so the proof is mandatory — deleting it fails CI, not review. - ADR-0054 proof-registry class bound to `field/readonly`, so the liveness ledger's `live` classification must carry the proof. - contract text: FieldSchema.readonly described itself as "Read-only in UI" — exactly the misreading #3003 documents. The Zod description, the field liveness ledger (readonly + readonlyWhen), and the hand-written field docs now state the server-side strip (non-system UPDATE, insert exempt). Closes #3003. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKR6MTrGunV4p4AUfbKMDU
1 parent 4c46ee0 commit 0f8db52

10 files changed

Lines changed: 130 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): `readonly` is server-enforced on UPDATE, not a UI-only affordance (#3003)
6+
7+
The `readonly` field property was described as "Read-only in UI", which #3003
8+
proved to be exactly how integrators read it — approval/status/amount columns
9+
protected only by `readonly: true` were forged with a direct REST `PATCH`,
10+
self-approving a multi-stage approval on the released 15.0.0. Since #2948 the
11+
engine strips caller-supplied writes to statically-readonly fields from every
12+
non-system UPDATE (single-id and multi-row, symmetric with `readonlyWhen`;
13+
INSERT may still seed the column). The schema description and the field
14+
liveness ledger now state the server-side contract, and a dogfood conformance
15+
proof (`showcase-static-readonly.dogfood.test.ts` + an authz-matrix row) pins
16+
it end-to-end so it cannot silently regress to renderer-only.

content/docs/data-modeling/fields.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ These properties are available on all field types:
280280
| `description` | `string` || Developer documentation |
281281
| `inlineHelpText` | `string` || Help text shown in UI |
282282
| `hidden` | `boolean` | `false` | Hide from default views |
283-
| `readonly` | `boolean` | `false` | Prevent editing |
283+
| `readonly` | `boolean` | `false` | Prevent editing — hidden from create/edit forms AND server-enforced: non-system `UPDATE` writes to the field are silently dropped (insert may still seed it) |
284284
| `sortable` | `boolean` | `true` | Allow sorting by this field |
285285
| `group` | `string` || Group name for organizing in forms (e.g. `'billing'`) |
286286

content/docs/data-modeling/validation-rules.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ These properties apply to **all** field types and are validated by the base `Fie
2727
| `unique` | `boolean` | `false` | Enforces database-level uniqueness constraint |
2828
| `multiple` | `boolean` | `false` | Stores value as array (applicable for select, lookup, file, image) |
2929
| `hidden` | `boolean` | `false` | Excluded from default UI rendering |
30-
| `readonly` | `boolean` | `false` | Blocks user edits in UI forms |
30+
| `readonly` | `boolean` | `false` | Blocks user edits in UI forms; server-enforced on update — a non-system write to the field is silently dropped from the payload (insert exempt) |
3131
| `sortable` | `boolean` | `true` | Whether field appears in list view sort options |
3232
| `index` | `boolean` | `false` | Creates standard database index |
3333
| `externalId` | `boolean` | `false` | Marks field as external ID for upsert operations |

packages/dogfood/test/authz-conformance.matrix.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
7777
note: 'These routes delegate straight to ObjectQL and were only shadowed when the REST plugin registered the same paths FIRST — so the posture depended on plugin registration order (a load-order change silently reopened it, no test failing). Gating each route makes the deny decision a property of this entry point too. Handler-level proof in plugin-hono-server/hono-anonymous-deny.test.ts.' },
7878
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
7979
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },
80+
{ id: 'readonly-static-write', summary: 'static `readonly: true` stripped from non-system UPDATE payloads (#2948 / #3003 — a direct PATCH cannot forge approval/status/amount columns the UI never renders)', state: 'enforced',
81+
enforcement: 'objectql/engine.ts update — stripReadonlyFields on both the single-id and multi-row paths (caller-supplied keys only, so audit-hook/middleware server stamps survive; isSystem exempt; symmetric with the readonlyWhen strip)',
82+
proof: 'showcase-static-readonly.dogfood.test.ts',
83+
note: 'The #3003 field report: `readonly: true` used to be UI-only, so a logged-in non-admin self-approved a 4-stage approval (approval_status/approval_stage/confirmed_total) with one same-session REST PATCH on a draft record — RECORD_LOCKED only guards pending flows, and the draft never entered one. The strip is SILENT (HTTP 200, persisted value kept — reject-vs-strip decided in #2948 for readonlyWhen symmetry). INSERT is deliberately exempt (create may seed a readonly column: defaultValue, import, migration), also symmetric with readonlyWhen. Engine-level unit/integration proof in objectql/plugin.integration.test.ts (#2948 suite: forge stripped, server stamp survives, system context allowed).' },
8084

8185
// ── ADR-0057 — ERP authorization core (enforced + e2e proven) ──────────
8286
{ id: 'scope-depth', summary: 'permission-grant access DEPTH (own/own_and_reports/unit/unit_and_below/org)', state: 'enforced',

packages/dogfood/test/authz-conformance.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ const HIGH_RISK = [
8080
// #2567 — every anonymous-deny HTTP surface is high-risk: it guards the
8181
// same object data as REST `/data` through a sibling entry point.
8282
'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data',
83+
// #2948/#3003 — write-integrity face: without the strip, `readonly: true`
84+
// is false compliance (declared ≠ enforced) and approval/status columns are
85+
// one direct PATCH away from self-approval.
86+
'readonly-static-write',
8387
];
8488

8589
describe('ADR-0056 D10 — authorization conformance matrix', () => {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// @proof: readonly-static-write
4+
//
5+
// #2948 / #3003 — static `readonly: true` is SERVER-enforced on UPDATE, not a
6+
// UI-only affordance. The #3003 field report: an approval-flow object declared
7+
// `approval_status` / `approval_stage` as `readonly: true`, the create/edit
8+
// forms never rendered them — and a logged-in, non-admin user forged all of
9+
// them (plus an amount column) with one direct REST PATCH from the same
10+
// session, self-approving a 4-stage approval. The strip added for #2948
11+
// (`stripReadonlyFields`, objectql/engine.ts) closes exactly that: on a
12+
// non-system UPDATE, caller-supplied writes to statically-readonly fields are
13+
// silently dropped (HTTP 200, persisted value kept) — symmetric with the
14+
// `readonlyWhen` strip. INSERT is deliberately exempt (a create may seed a
15+
// readonly column: defaultValue, import, migration), matching `readonlyWhen`.
16+
//
17+
// Proven here on the REAL showcase app over HTTP: `showcase_contact.lead_score`
18+
// is the stand-in for the #3003 approval/status/amount columns — readonly,
19+
// "computed by scoring rules — not user-editable", never on the create form.
20+
21+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22+
import showcaseStack from '@objectstack/example-showcase';
23+
import { bootStack, type VerifyStack } from '@objectstack/verify';
24+
25+
const OBJ = '/data/showcase_contact';
26+
const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId;
27+
const recordOf = (b: any) => b?.record ?? b?.data ?? b;
28+
29+
describe('showcase: static readonly write enforcement (#2948 / #3003)', () => {
30+
let stack: VerifyStack;
31+
let token: string;
32+
let contactId: string;
33+
34+
beforeAll(async () => {
35+
stack = await bootStack(showcaseStack);
36+
await stack.signIn();
37+
token = await stack.signUp('ro-worker@verify.test');
38+
39+
// INSERT exemption (documented contract, symmetric with `readonlyWhen`):
40+
// a create MAY seed a readonly column — the scoring pipeline, an import,
41+
// or a migration legitimately writes the initial value.
42+
const created = await stack.apiAs(token, 'POST', OBJ, {
43+
name: 'Readonly Probe',
44+
email: 'ro-probe@verify.test',
45+
lead_score: 10,
46+
});
47+
expect(created.status).toBeLessThan(300);
48+
contactId = idOf(await created.json());
49+
expect(contactId).toBeTruthy();
50+
}, 60_000);
51+
52+
afterAll(async () => { await stack?.stop(); });
53+
54+
it('INSERT may seed the readonly field (documented exemption)', async () => {
55+
const res = await stack.apiAs(token, 'GET', `${OBJ}/${contactId}`);
56+
expect(res.status).toBe(200);
57+
expect(recordOf(await res.json()).lead_score, 'insert-seeded value persisted').toBe(10);
58+
});
59+
60+
it('a direct PATCH forging the readonly field is silently stripped — sibling editable fields still land', async () => {
61+
// The #3003 move: same logged-in session, straight to the REST API with a
62+
// payload the UI would never produce.
63+
const forge = await stack.apiAs(token, 'PATCH', `${OBJ}/${contactId}`, {
64+
lead_score: 99999,
65+
notes: 'legitimate edit in the same payload',
66+
});
67+
// The strip is SILENT by contract (like `readonlyWhen`): 200, not 4xx.
68+
expect(forge.status, 'strip is silent — the request succeeds').toBe(200);
69+
70+
const after = recordOf(await (await stack.apiAs(token, 'GET', `${OBJ}/${contactId}`)).json());
71+
expect(after.lead_score, 'forged readonly value must NOT persist').toBe(10);
72+
expect(after.notes, 'editable field from the same payload still lands').toBe(
73+
'legitimate edit in the same payload',
74+
);
75+
});
76+
77+
it('a PATCH carrying ONLY the forged readonly field is a no-op on the record', async () => {
78+
const forge = await stack.apiAs(token, 'PATCH', `${OBJ}/${contactId}`, { lead_score: -1 });
79+
expect(forge.status).toBe(200);
80+
81+
const after = recordOf(await (await stack.apiAs(token, 'GET', `${OBJ}/${contactId}`)).json());
82+
expect(after.lead_score, 'readonly value survives an all-forged payload').toBe(10);
83+
});
84+
});

packages/spec/liveness/field.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@
7474
},
7575
"readonlyWhen": {
7676
"status": "live",
77-
"note": "renderer CEL."
77+
"evidence": "packages/objectql/src/validation/rule-validator.ts",
78+
"note": "renderer CEL + server write path: stripReadonlyWhenFields drops UPDATE writes when the predicate is TRUE against {...previous, ...data} (B2); INSERT exempt."
7879
},
7980
"visibleWhen": {
8081
"status": "live",
@@ -86,7 +87,9 @@
8687
},
8788
"readonly": {
8889
"status": "live",
89-
"note": "renderer."
90+
"evidence": "packages/objectql/src/validation/rule-validator.ts",
91+
"proof": "packages/dogfood/test/showcase-static-readonly.dogfood.test.ts#readonly-static-write",
92+
"note": "renderer + server write path: stripReadonlyFields drops non-system UPDATE writes to the field (#2948/#3003 — was renderer-only, i.e. false compliance for approval/status columns); INSERT exempt, symmetric with readonlyWhen."
9093
},
9194
"hidden": {
9295
"status": "live",

packages/spec/scripts/liveness/proof-registry.mts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [
143143
// a declaration whose sweeper stopped running is dead surface (ADR-0049).
144144
ledgerBindings: [{ type: 'object', path: 'lifecycle' }],
145145
},
146+
{
147+
id: 'readonly-static-write',
148+
label: 'Static readonly write enforcement',
149+
summary:
150+
'a statically `readonly: true` field cannot be forged by a non-system UPDATE — the write is stripped server-side, not merely hidden by the form (#2948/#3003: approval/status/amount columns "protected" only by readonly were one direct PATCH away from self-approval).',
151+
proofId: 'readonly-static-write',
152+
proofRef: 'packages/dogfood/test/showcase-static-readonly.dogfood.test.ts#readonly-static-write',
153+
bound: true,
154+
// `readonly` was renderer-only until #2948 — declared ≠ enforced is exactly
155+
// the false-compliance class ADR-0049 closes. The proof pins the server-side
156+
// strip (forge dropped, sibling edit lands, insert exempt) over real HTTP.
157+
ledgerBindings: [{ type: 'field', path: 'readonly' }],
158+
},
146159
{
147160
id: 'form-widget',
148161
label: 'Form layout / section / widget',

packages/spec/scripts/liveness/proof-registry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ describe('registry invariants', () => {
104104
expect([...BOUND_PROOF_PATHS.keys()].sort()).toEqual(
105105
[
106106
'field/type',
107+
'field/readonly',
107108
'flow/nodes.type',
108109
'flow/runAs',
109110
'permission/rowLevelSecurity.using',

packages/spec/src/data/field.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,7 +613,7 @@ export const FieldSchema = lazySchema(() => z.object({
613613

614614
/** Security & Visibility */
615615
hidden: z.boolean().default(false).describe('Hidden from default UI'),
616-
readonly: z.boolean().default(false).describe('Read-only in UI'),
616+
readonly: z.boolean().default(false).describe('Read-only — never editable in forms, AND server-enforced on UPDATE: a non-system write to this field is silently dropped from the payload (#2948/#3003; symmetric with `readonlyWhen`). INSERT may still seed it (defaultValue, import).'),
617617

618618
/**
619619
* [ADR-0066 D3] Capabilities required to READ/EDIT this field. A field

0 commit comments

Comments
 (0)