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
48 changes: 48 additions & 0 deletions .changeset/autonumber-builder-readonly.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
"@objectstack/spec": minor
"@objectstack/metadata-protocol": patch
---

feat(spec): `Field.autonumber` declares the field `readonly: true` (#5628)

`FieldSchema.readonly` is a **two-part** contract: "never editable in forms"
AND server-enforced on both write paths. #5503 closed the server half for
`autonumber` **by type** — a caller-supplied record number is stripped before
any driver sees it, flag or no flag. The form half is keyed on the **flag**, and
`Field.autonumber` never set it. So an authoring/rendering layer that decides
editability from `field.readonly` drew an editable "record number" input whose
value the server was already guaranteed to discard: the user types one, the
create succeeds, and the record comes back carrying the number the sequence
issued instead. Data was never at risk (that half has been enforced since
#5503/#5627); what was wrong is what the form told the user.

`Field.autonumber(...)` now emits `readonly: true`. The injection is applied
**after** the author's config, so it cannot be spread away, and the authoring
type rejects the one config that contradicts it — `Field.autonumber({ readonly:
false })` is a **compile error** rather than a silently coerced value, because
an "editable record number" is not a state the runtime can deliver. Restating
`readonly: true` stays legal. A hand-written `{ type: 'autonumber' }` literal
(YAML/JSON metadata, or a plain object in TS) is unchanged and unaffected: it is
covered by the by-type server enforcement, which never depended on the flag.

Two consequences worth knowing:

- **A flow that writes an autonumber field is now caught at `os validate`.**
`flow-update-readonly-field` reads the static flag, so an `update_record` node
writing a builder-authored record number — already a silent no-op at run time
— is now reported at design time instead of in server WARN logs.
- **The historical-import exemption is unchanged**, and stays that way by
construction. The DataProtocol create ingress (`stripReadonlyForInsert`,
#3043) knows only the `isSystem` exemption, while the engine's runtime-owned
strip also honours `preserveAudit` (#3493 — a migration reinstating legacy
record numbers). Now that the field carries the flag, the ingress would have
deleted that value *before* the engine could keep it, so the ingress skips
runtime-owned field types outright and leaves them to the engine strip, which
runs on every insert path (including the direct `engine.insert` callers the
ingress never sees). Author-declared `readonly` on every other field type is
stripped at the ingress exactly as wide as before.

The set backing "which types the runtime owns" is now declared once in the
protocol — `RUNTIME_OWNED_FIELD_TYPES`, exported from `@objectstack/spec/data`
— and read by both consumers (objectql's write-path strips, the DataProtocol
ingress) instead of each carrying its own literal.
18 changes: 18 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import {
parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf,
AggregationFunction, DateGranularity, resolveSearchFieldResolution,
SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS,
RUNTIME_OWNED_FIELD_TYPES,
RPC_QUERY_ALIAS_SLOTS, foldQueryAliasSlots,
type QueryAliasConflict, type QueryAliasSlot,
type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed,
Expand DownExpand Up@@ -1026,6 +1027,20 @@ const CLONE_STRIP_FIELDS: readonly string[] = [
* reject. The #3043 threat is app approval/status/verdict fields (the issue's
* `sporadic_application` / `assessment`), never `sys_`; this is the same
* platform-vs-authored boundary `applySystemFields` uses for ownership.
*
* SCOPE, second boundary — RUNTIME-OWNED field types
* ({@link RUNTIME_OWNED_FIELD_TYPES}: today `autonumber`) are left to the
* ENGINE's own insert strip (`stripRuntimeOwnedFields`, #5503), which runs on
* every insert path including the direct `engine.insert` callers this ingress
* never sees. Skipping them here removes no protection and prevents this seam
* from PRE-EMPTING an exemption it does not implement: the engine strip honours
* `preserveAudit` (#3493 — a historical import reinstating legacy record
* numbers) while this one knows only `isSystem`. Before #5628 the distinction
* was academic, because an `autonumber` field carried no `readonly` flag for the
* loop below to notice; now that `Field.autonumber` injects one, stripping here
* would silently delete the value a historical import is entitled to keep,
* BEFORE the engine could apply the whitelist. Author-declared `readonly` on
* every other type is untouched — the #3043 strip is exactly as wide as it was.
*/
function stripReadonlyForInsert(schema: any, data: any, context: any): any {
if (context?.isSystem) return data;
Expand All@@ -1037,6 +1052,9 @@ function stripReadonlyForInsert(schema: any, data: any, context: any): any {
let out = row;
for (const name of Object.keys(fields)) {
if (!fields[name]?.readonly) continue;
// [#5628] The engine's runtime-owned strip owns these, with the
// wider exemption set. See the note above.
if (RUNTIME_OWNED_FIELD_TYPES.has(String(fields[name]?.type ?? ''))) continue;
if (!(name in out)) continue;
if (out === row) out = { ...row };
delete out[name];
Expand Down
110 changes: 110 additions & 0 deletions packages/objectql/src/engine-autonumber-runtime-owned.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -537,3 +537,113 @@ describe('#5503 — autonumber is runtime-owned: UPDATE', () => {
expect(res.account_number).toBe('HOOK-0002');
});
});

/**
* #5628 — the FLAGGED autonumber field: `Field.autonumber` now injects
* `readonly: true` so the form half of the `readonly` contract ("never editable
* in forms") holds for a record number the server was already guaranteed to
* discard. Every case above uses an UNflagged `type: 'autonumber'`, so none of
* them sees what that flag changes on the way in.
*
* What it changes is WHICH strip gets to the field first. A `readonly` field is
* also stripped at the DataProtocol create INGRESS (`stripReadonlyForInsert`,
* #3043) — a seam that knows only the `isSystem` exemption, while the engine's
* runtime-owned strip also honours `preserveAudit` (#3493: a historical import
* reinstating legacy record numbers). Left alone, the flag would therefore have
* SILENTLY narrowed a documented exemption: the ingress would delete the legacy
* number before the engine could keep it, with no test anywhere going red,
* because every existing preserveAudit pin calls `engine.insert` directly.
*
* So the ingress skips runtime-owned types outright (they are covered by the
* engine strip on EVERY insert path, including the direct `engine.insert`
* callers the ingress never sees) and these cases pin both halves of that: the
* ordinary caller is still stripped, and the historical import still keeps its
* value — flagged or not, the verdicts are identical.
*/
describe('#5628 — a `readonly: true` autonumber keeps the #5503 exemption set', () => {
// What `Field.autonumber({ label: 'Invoice No.' })` produces since #5628.
const INVOICE = {
name: 'an_invoice',
label: 'Invoice',
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
name: { name: 'name', label: 'Name', type: 'text' as const },
invoice_number: {
name: 'invoice_number',
label: 'Invoice No.',
type: 'autonumber' as const,
readonly: true,
autonumberFormat: 'INV-{0000}',
},
},
};

let rig: Awaited<ReturnType<typeof makeEngine>>;
beforeEach(async () => {
rig = await makeEngine();
rig.engine.registry.registerObject(INVOICE as any, 'test');
});

it('still strips an ordinary caller-supplied number and issues the sequence value', async () => {
const created = await rig.protocol.createData({
object: 'an_invoice',
data: { name: 'forge', invoice_number: 'INV-9999' },
});
expect(created.record.invoice_number).toBe('INV-0001');
expect(rig.createdRows[rig.createdRows.length - 1]?.invoice_number).toBe('INV-0001');
});

it('still REPORTS the strip to the caller (#3407 / #3431)', async () => {
const created = await rig.protocol.createData({
object: 'an_invoice',
data: { name: 'forge', invoice_number: 'INV-9999' },
});
const dropped = (created as { droppedFields?: DroppedFieldsEvent[] }).droppedFields ?? [];
expect(dropped.flatMap((e) => e.fields)).toContain('invoice_number');
});

it('keeps a legacy number for a `preserveAudit` historical import THROUGH THE INGRESS (#3493)', async () => {
// The regression this whole describe exists for: the ingress strip has no
// `preserveAudit` exemption, so if it acted on the flag the value would be
// gone before the engine's whitelist ran.
const created = await rig.protocol.createData({
object: 'an_invoice',
data: { name: 'legacy', invoice_number: 'LEGACY-0007' },
context: { preserveAudit: true },
});
expect(created.record.invoice_number).toBe('LEGACY-0007');
});

it('keeps an explicit number for a system write through the ingress', async () => {
const created = await rig.protocol.createData({
object: 'an_invoice',
data: { name: 'seeded', invoice_number: 'INV-000042' },
context: { isSystem: true },
});
expect(created.record.invoice_number).toBe('INV-000042');
});

it('an author-declared `readonly` field of an ORDINARY type is still stripped at the ingress', async () => {
// The #3043 strip keeps its full width — only runtime-owned types moved.
rig.engine.registry.registerObject({
name: 'an_case',
label: 'Case',
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
title: { name: 'title', label: 'Title', type: 'text' as const },
approval_status: {
name: 'approval_status',
label: 'Approval',
type: 'text' as const,
readonly: true,
defaultValue: 'draft',
},
},
} as any, 'test');
const created = await rig.protocol.createData({
object: 'an_case',
data: { title: 'forged', approval_status: 'approved' },
});
expect(created.record.approval_status).toBe('draft');
});
});
11 changes: 9 additions & 2 deletions packages/objectql/src/validation/rule-validator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,7 +190,7 @@

import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formula';
import type { Expression } from '@objectstack/spec';
import { AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data';
import { AUDIT_PROVENANCE_FIELDS, RUNTIME_OWNED_FIELD_TYPES } from '@objectstack/spec/data';
import Ajv, { type ValidateFunction } from 'ajv';
// #5029 — `format` is NOT built into ajv 8; it ships in this separate package.
// See the `const ajv` note below for why the runtime registers it.
Expand DownExpand Up@@ -821,8 +821,15 @@ export function stripReadonlyWhenFieldsMulti(
*
* Keep this set to types whose value is (a) persisted, (b) issued by the
* runtime, and (c) never legitimately supplied by a caller.
*
* The set itself now lives in `@objectstack/spec` (`RUNTIME_OWNED_FIELD_TYPES`,
* #5628) — the protocol's one statement of the ownership — because a SECOND
* consumer needs it: the DataProtocol create ingress, whose `readonly` strip
* carries a NARROWER exemption set than this module's (no `preserveAudit`), and
* which therefore has to recognise these types to stay out of their way. A
* literal copied over there is the drift `AUDIT_TIMELINE_FIELDS` below stopped
* paying for. This module keeps the reasoning; the membership is imported.
*/
const RUNTIME_OWNED_FIELD_TYPES: ReadonlySet<string> = new Set(['autonumber']);

/**
* Whether the runtime owns this field's value outright — i.e. the field is
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@
"REFERENCE_VALUE_TYPES (const)",
"RETIRED_FILTER_OPERATORS (const)",
"RPC_QUERY_ALIAS_SLOTS (const)",
"RUNTIME_OWNED_FIELD_TYPES (const)",
"RangeOperatorSchema (const)",
"RecordFlow (type)",
"RecordFlowContainer (type)",
Expand Down
94 changes: 94 additions & 0 deletions packages/spec/src/data/field-autonumber-readonly.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #5628 — `Field.autonumber` declares the field `readonly: true`.
*
* `FieldSchema.readonly` is a TWO-part contract: "never editable in forms" AND
* server-enforced on both write paths. #5503 / PR #5627 closed the server half
* for `autonumber` BY TYPE ({@link RUNTIME_OWNED_FIELD_TYPES}) — a
* caller-supplied record number is stripped before any driver sees it, flag or
* no flag. The FORM half is keyed on the FLAG, and the builder did not set it,
* so an authoring/rendering layer deciding editability from `field.readonly`
* drew an editable "record number" input whose value the server was already
* guaranteed to discard: the user types one, the create succeeds, and the
* record comes back carrying the number the sequence issued instead (#4632's
* "second-class" shape — the write path reports the drop in `droppedFields`,
* but a renderer need not surface that).
*
* These cases pin the flag on the builder's output, and the AUTHORING-TIME
* verdict on the one config that contradicts it. `readonly: false` on an
* autonumber field is not a preference the runtime can honour — the value is
* issued by the engine or the driver's persistent sequence, so an "editable
* record number" cannot exist. It is rejected at the authoring site by `tsc`
* (a loud compile error where the metadata is written) rather than accepted and
* silently coerced, which is the lenient-consumer shape ADR-aligned authoring
* exists to avoid.
*/

import { describe, it, expect } from 'vitest';
import { Field, FieldSchema, RUNTIME_OWNED_FIELD_TYPES } from './field.zod';

describe('#5628 — Field.autonumber injects readonly: true', () => {
it('declares the field read-only', () => {
const f = Field.autonumber({ label: 'Auto Number' });
expect(f.type).toBe('autonumber');
expect(f.readonly).toBe(true);
});

it('keeps every other config key the author wrote', () => {
const f = Field.autonumber({ label: 'Invoice No.', autonumberFormat: 'INV-{0000}', required: true });
expect(f).toMatchObject({
type: 'autonumber',
label: 'Invoice No.',
autonumberFormat: 'INV-{0000}',
required: true,
readonly: true,
});
});

it('sets the flag with no config at all', () => {
expect(Field.autonumber().readonly).toBe(true);
});

it('restating `readonly: true` is allowed and changes nothing', () => {
expect(Field.autonumber({ readonly: true, label: 'No.' }).readonly).toBe(true);
});

it('rejects `readonly: false` at the authoring site (compile error), not at runtime', () => {
// The pin: this line must NOT compile. `tsconfig.test.json` puts this file
// in front of `tsc` (#5286), so the directive is a real check — delete the
// injection's type narrowing and `pnpm --filter @objectstack/spec typecheck`
// fails on an unused @ts-expect-error.
// @ts-expect-error an autonumber field is runtime-owned: `readonly` is always true
const f = Field.autonumber({ readonly: false });
// And a caller that reaches the builder from untyped JS (or through a cast)
// still gets the flag: the injection is applied AFTER `config`, so it cannot
// be spread away. Silent coercion is acceptable ONLY because the authoring
// surface above rejects the same input loudly.
expect(f.readonly).toBe(true);
});

it('the builder output parses as a valid field (the flag is a real authoring key)', () => {
const parsed = FieldSchema.safeParse({
name: 'invoice_number',
label: 'Invoice No.',
...Field.autonumber({ autonumberFormat: 'INV-{0000}' }),
});
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.readonly).toBe(true);
});
});

describe('#5628 / #5503 — RUNTIME_OWNED_FIELD_TYPES is the protocol vocabulary', () => {
it('names `autonumber`', () => {
expect(RUNTIME_OWNED_FIELD_TYPES.has('autonumber')).toBe(true);
});

it('does NOT name the other calculated types, nor ordinary ones', () => {
// `formula` is computed on read (no stored caller value to strip);
// `summary` is a stored roll-up a caller MAY legitimately seed (#6014).
for (const t of ['formula', 'summary', 'text', 'number']) {
expect(RUNTIME_OWNED_FIELD_TYPES.has(t)).toBe(false);
}
});
});
43 changes: 42 additions & 1 deletion packages/spec/src/data/field.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,6 +78,25 @@ export const FieldType = z.enum([

export type FieldType = z.input<typeof FieldType>;

/**
* Field types whose stored value the RUNTIME owns outright — issued by the
* engine (or the driver's persistent sequence), never supplied by a caller on
* either write path. Today exactly `autonumber` (#5503).
*
* This is the PROTOCOL's statement of that ownership, so the consumers that act
* on it read one vocabulary instead of each carrying its own literal: objectql's
* write-path strips (`isRuntimeOwnedField` / `stripRuntimeOwnedFields`, which
* treat these types as implicitly read-only), and the DataProtocol create
* ingress, which defers to those strips rather than pre-empting them with its
* own narrower exemption set (`stripReadonlyForInsert`, #5628).
*
* Keep the set to types whose value is (a) persisted, (b) issued by the runtime,
* and (c) never legitimately supplied by a caller. `formula` and `summary` are
* deliberately NOT here: they are derived-on-read/roll-up, not stored values a
* caller could forge into a sequence.
*/
export const RUNTIME_OWNED_FIELD_TYPES: ReadonlySet<string> = new Set<string>(['autonumber']);

/**
* Select Option Schema
*
Expand DownExpand Up@@ -910,7 +929,29 @@ export const Field = {
avatar: (config: FieldInput = {}) => ({ type: 'avatar', ...config } as const),
formula: (config: FieldInput = {}) => ({ type: 'formula', ...config } as const),
summary: (config: FieldInput = {}) => ({ type: 'summary', ...config } as const),
autonumber: (config: FieldInput = {}) => ({ type: 'autonumber', ...config } as const),
/**
* Auto-number — a record number the RUNTIME issues from its sequence.
*
* The builder injects `readonly: true` (#5628). `readonly` is a TWO-part
* contract (see `FieldSchema.readonly`): "never editable in forms" AND
* server-enforced on both write paths. #5503 closed the server half for
* `autonumber` by type ({@link RUNTIME_OWNED_FIELD_TYPES}), but the FORM half
* is keyed on the flag — so without it a renderer drew an editable "record
* number" box whose value the server was already guaranteed to discard: the
* user types a number, the create succeeds, and the record comes back
* carrying a different one. Declaring the flag the builder's output already
* behaves like is the shortest "declared = enforced" path.
*
* The injection is UNCONDITIONAL — it is applied after `config`, so it cannot
* be spread away — and `readonly: false` is a compile error at the authoring
* site rather than a silent coercion: an autonumber field is runtime-owned by
* construction, so "editable record number" is not a state the author can
* ask for. Restating `readonly: true` is allowed (it is merely redundant).
* A hand-written `{ type: 'autonumber' }` literal is unaffected — it is
* covered by the by-TYPE server enforcement, which never depended on the flag.
*/
autonumber: (config: FieldInput & { readonly?: true } = {}) =>
({ type: 'autonumber', ...config, readonly: true } as const),
markdown: (config: FieldInput = {}) => ({ type: 'markdown', ...config } as const),
html: (config: FieldInput = {}) => ({ type: 'html', ...config } as const),
password: (config: FieldInput = {}) => ({ type: 'password', ...config } as const),
Expand Down
Loading