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
16 changes: 16 additions & 0 deletions .changeset/docs-3003-readonly-server-enforced.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': patch
---

docs(spec): `readonly` is server-enforced on UPDATE, not a UI-only affordance (#3003)

The `readonly` field property was described as "Read-only in UI", which #3003
proved to be exactly how integrators read it — approval/status/amount columns
protected only by `readonly: true` were forged with a direct REST `PATCH`,
self-approving a multi-stage approval on the released 15.0.0. Since #2948 the
engine strips caller-supplied writes to statically-readonly fields from every
non-system UPDATE (single-id and multi-row, symmetric with `readonlyWhen`;
INSERT may still seed the column). The schema description and the field
liveness ledger now state the server-side contract, and a dogfood conformance
proof (`showcase-static-readonly.dogfood.test.ts` + an authz-matrix row) pins
it end-to-end so it cannot silently regress to renderer-only.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ These properties are available on all field types:
| `description` | `string` | — | Developer documentation |
| `inlineHelpText` | `string` | — | Help text shown in UI |
| `hidden` | `boolean` | `false` | Hide from default views |
| `readonly` | `boolean` | `false` | Prevent editing |
| `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) |
| `sortable` | `boolean` | `true` | Allow sorting by this field |
| `group` | `string` | — | Group name for organizing in forms (e.g. `'billing'`) |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/data-modeling/validation-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ These properties apply to **all** field types and are validated by the base `Fie
| `unique` | `boolean` | `false` | Enforces database-level uniqueness constraint |
| `multiple` | `boolean` | `false` | Stores value as array (applicable for select, lookup, file, image) |
| `hidden` | `boolean` | `false` | Excluded from default UI rendering |
| `readonly` | `boolean` | `false` | Blocks user edits in UI forms |
| `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) |
| `sortable` | `boolean` | `true` | Whether field appears in list view sort options |
| `index` | `boolean` | `false` | Creates standard database index |
| `externalId` | `boolean` | `false` | Marks field as external ID for upsert operations |
Expand Down
4 changes: 4 additions & 0 deletions packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
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.' },
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },
{ 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',
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)',
proof: 'showcase-static-readonly.dogfood.test.ts',
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).' },

// ── ADR-0057 — ERP authorization core (enforced + e2e proven) ──────────
{ id: 'scope-depth', summary: 'permission-grant access DEPTH (own/own_and_reports/unit/unit_and_below/org)', state: 'enforced',
Expand Down
4 changes: 4 additions & 0 deletions packages/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ const HIGH_RISK = [
// #2567 — every anonymous-deny HTTP surface is high-risk: it guards the
// same object data as REST `/data` through a sibling entry point.
'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data',
// #2948/#3003 — write-integrity face: without the strip, `readonly: true`
// is false compliance (declared ≠ enforced) and approval/status columns are
// one direct PATCH away from self-approval.
'readonly-static-write',
];

describe('ADR-0056 D10 — authorization conformance matrix', () => {
Expand Down
84 changes: 84 additions & 0 deletions packages/dogfood/test/showcase-static-readonly.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// @proof: readonly-static-write
//
// #2948 / #3003 — static `readonly: true` is SERVER-enforced on UPDATE, not a
// UI-only affordance. The #3003 field report: an approval-flow object declared
// `approval_status` / `approval_stage` as `readonly: true`, the create/edit
// forms never rendered them — and a logged-in, non-admin user forged all of
// them (plus an amount column) with one direct REST PATCH from the same
// session, self-approving a 4-stage approval. The strip added for #2948
// (`stripReadonlyFields`, objectql/engine.ts) closes exactly that: on a
// non-system UPDATE, caller-supplied writes to statically-readonly fields are
// silently dropped (HTTP 200, persisted value kept) — symmetric with the
// `readonlyWhen` strip. INSERT is deliberately exempt (a create may seed a
// readonly column: defaultValue, import, migration), matching `readonlyWhen`.
//
// Proven here on the REAL showcase app over HTTP: `showcase_contact.lead_score`
// is the stand-in for the #3003 approval/status/amount columns — readonly,
// "computed by scoring rules — not user-editable", never on the create form.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import showcaseStack from '@objectstack/example-showcase';
import { bootStack, type VerifyStack } from '@objectstack/verify';

const OBJ = '/data/showcase_contact';
const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId;
const recordOf = (b: any) => b?.record ?? b?.data ?? b;

describe('showcase: static readonly write enforcement (#2948 / #3003)', () => {
let stack: VerifyStack;
let token: string;
let contactId: string;

beforeAll(async () => {
stack = await bootStack(showcaseStack);
await stack.signIn();
token = await stack.signUp('ro-worker@verify.test');

// INSERT exemption (documented contract, symmetric with `readonlyWhen`):
// a create MAY seed a readonly column — the scoring pipeline, an import,
// or a migration legitimately writes the initial value.
const created = await stack.apiAs(token, 'POST', OBJ, {
name: 'Readonly Probe',
email: 'ro-probe@verify.test',
lead_score: 10,
});
expect(created.status).toBeLessThan(300);
contactId = idOf(await created.json());
expect(contactId).toBeTruthy();
}, 60_000);

afterAll(async () => { await stack?.stop(); });

it('INSERT may seed the readonly field (documented exemption)', async () => {
const res = await stack.apiAs(token, 'GET', `${OBJ}/${contactId}`);
expect(res.status).toBe(200);
expect(recordOf(await res.json()).lead_score, 'insert-seeded value persisted').toBe(10);
});

it('a direct PATCH forging the readonly field is silently stripped — sibling editable fields still land', async () => {
// The #3003 move: same logged-in session, straight to the REST API with a
// payload the UI would never produce.
const forge = await stack.apiAs(token, 'PATCH', `${OBJ}/${contactId}`, {
lead_score: 99999,
notes: 'legitimate edit in the same payload',
});
// The strip is SILENT by contract (like `readonlyWhen`): 200, not 4xx.
expect(forge.status, 'strip is silent — the request succeeds').toBe(200);

const after = recordOf(await (await stack.apiAs(token, 'GET', `${OBJ}/${contactId}`)).json());
expect(after.lead_score, 'forged readonly value must NOT persist').toBe(10);
expect(after.notes, 'editable field from the same payload still lands').toBe(
'legitimate edit in the same payload',
);
});

it('a PATCH carrying ONLY the forged readonly field is a no-op on the record', async () => {
const forge = await stack.apiAs(token, 'PATCH', `${OBJ}/${contactId}`, { lead_score: -1 });
expect(forge.status).toBe(200);

const after = recordOf(await (await stack.apiAs(token, 'GET', `${OBJ}/${contactId}`)).json());
expect(after.lead_score, 'readonly value survives an all-forged payload').toBe(10);
});
});
7 changes: 5 additions & 2 deletions packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
},
"readonlyWhen": {
"status": "live",
"note": "renderer CEL."
"evidence": "packages/objectql/src/validation/rule-validator.ts",
"note": "renderer CEL + server write path: stripReadonlyWhenFields drops UPDATE writes when the predicate is TRUE against {...previous, ...data} (B2); INSERT exempt."
},
"visibleWhen": {
"status": "live",
Expand All @@ -86,7 +87,9 @@
},
"readonly": {
"status": "live",
"note": "renderer."
"evidence": "packages/objectql/src/validation/rule-validator.ts",
"proof": "packages/dogfood/test/showcase-static-readonly.dogfood.test.ts#readonly-static-write",
"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."
},
"hidden": {
"status": "live",
Expand Down
13 changes: 13 additions & 0 deletions packages/spec/scripts/liveness/proof-registry.mts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,19 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [
// a declaration whose sweeper stopped running is dead surface (ADR-0049).
ledgerBindings: [{ type: 'object', path: 'lifecycle' }],
},
{
id: 'readonly-static-write',
label: 'Static readonly write enforcement',
summary:
'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).',
proofId: 'readonly-static-write',
proofRef: 'packages/dogfood/test/showcase-static-readonly.dogfood.test.ts#readonly-static-write',
bound: true,
// `readonly` was renderer-only until #2948 — declared ≠ enforced is exactly
// the false-compliance class ADR-0049 closes. The proof pins the server-side
// strip (forge dropped, sibling edit lands, insert exempt) over real HTTP.
ledgerBindings: [{ type: 'field', path: 'readonly' }],
},
{
id: 'form-widget',
label: 'Form layout / section / widget',
Expand Down
1 change: 1 addition & 0 deletions packages/spec/scripts/liveness/proof-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ describe('registry invariants', () => {
expect([...BOUND_PROOF_PATHS.keys()].sort()).toEqual(
[
'field/type',
'field/readonly',
'flow/nodes.type',
'flow/runAs',
'permission/rowLevelSecurity.using',
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ export const FieldSchema = lazySchema(() => z.object({

/** Security & Visibility */
hidden: z.boolean().default(false).describe('Hidden from default UI'),
readonly: z.boolean().default(false).describe('Read-only in UI'),
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).'),

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