Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
025a801
wip(objectql,metadata-protocol): move the create-side static readonly…
claude Sep 4, 2026
849f4e8
test(objectql,metadata-protocol): pin the in-engine create-side reado…
claude Sep 4, 2026
a45ae31
test(objectql): the referential-integrity narrowing, split into its p…
claude Sep 4, 2026
9178f36
fix(lint,metadata-protocol): drop the superseded INSERT-exempt premis…
claude Sep 4, 2026
3ffcf3d
docs(objectql,metadata-protocol,spec,lint): retire the ingress-strip …
claude Sep 4, 2026
2b39370
fix(objectql,rest): the create-path strip line states only what is tr…
claude Sep 4, 2026
95fb4c4
wip: suite runs
claude Sep 4, 2026
4935e0e
Merge remote-tracking branch 'origin/main' into claude/issue-14147-en…
claude Sep 4, 2026
2159506
chore(objectql,lint,scripts): self-contained prose, engine-double pin…
claude Sep 4, 2026
2921eef
docs(permissions): the system-context census loses the ingress row it…
claude Sep 4, 2026
341baaf
Merge remote-tracking branch 'origin/main' into claude/issue-14147-en…
claude Sep 4, 2026
7997126
test(objectql): pin the create-path preserveAudit line by its remedy,…
claude Sep 4, 2026
b6ce61b
Merge remote-tracking branch 'origin/main' into claude/issue-14147-en…
claude Sep 4, 2026
3120aa6
chore: regenerate the system-context census line anchors after mergin…
claude Sep 4, 2026
0c52155
Merge remote-tracking branch 'origin/main' into claude/issue-14147-en…
claude Sep 4, 2026
484cec1
chore(changeset): answer the ADR-0087 disposition and re-derive the r…
claude Sep 4, 2026
e885c44
test(example-todo): bind the app's own hook in the recurrence harness…
claude Sep 4, 2026
67d6144
docs(example-todo): the sibling's measurement table is history, not a…
claude Sep 4, 2026
7dbfaaf
Merge remote-tracking branch 'origin/main' into claude/issue-14147-en…
claude Sep 4, 2026
bd598e8
chore(docs): re-anchor the system-context census after merging origin…
claude Sep 4, 2026
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
18 changes: 18 additions & 0 deletions .changeset/tidy-cups-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/objectql': minor
'@objectstack/metadata-protocol': minor
'@objectstack/service-automation': patch
'@objectstack/lint': patch
'@objectstack/spec': patch
---

**BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed.

Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event.

- `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face (`createData`, `cloneData`, `createManyData`, `insertManyData`, `batchData`) now hands the caller's payload to the engine whole and reports the engine's own verdict, so `droppedFields` says the same thing at every seam.
- `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not.
- Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL.
- `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. The lint rules drop the superseded "INSERT is exempt" premise (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens), and the spec change is one docblock sentence that named the deleted function.

<!-- adr-0087: not-required (no-migration-prescription) The BREAKING here is a WRITE-PATH BEHAVIOUR change, not a retirement of an authorable or published surface, so there is nothing for the ledger to carry to `objectstack migrate meta`, `spec-changes.json` or the upgrade guide: no spec property, metadata key, accepted value or exported symbol disappears, and this body prescribes no FROM/TO migration. The remedy for an affected caller is to declare the write trusted (`context.isSystem` / `runAs: 'system'`), which is application code, not a metadata migration. The obvious retirement candidate is a non-question in the same direction: `stripReadonlyForInsert` was a bare module-private `function` in `packages/metadata-protocol/src/protocol.ts`, absent from that package's `index.ts` (the only path its `exports` map offers), so no consumer could name it. -->
121 changes: 60 additions & 61 deletions content/docs/permissions/system-context.mdx

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,11 @@ field, but cannot *rescue* one the caller supplied.

<Callout type="warn">
**`preserveAudit` is an UPDATE-path exemption. It does not apply on INSERT (#6640).**
The two write paths run two different strips: UPDATE is stripped inside the engine
(`stripReadonlyFields`), which consults `preserveAudit`; CREATE is stripped earlier, at the
DataProtocol ingress (`stripReadonlyForInsert`, #3043), whose only exemption is
`context.isSystem`. So one historical import that *upserts* keeps an author-declared
`readonly` column on the rows it **updates** and strips it from the rows it **creates**.
Both write paths run the same strip inside the engine (`stripReadonlyFields`, #14147), but
they read it differently: UPDATE consults `preserveAudit`, while CREATE is passed no such
flag and keeps `context.isSystem` as its only exemption. So one historical import that
*upserts* keeps an author-declared `readonly` column on the rows it **updates** and strips
it from the rows it **creates**.

That asymmetry is deliberate, not an oversight: `treatAsHistorical` arrives on an ordinary
(non-system) REST import request, so honouring it on create would let any caller seed the
Expand Down
2 changes: 1 addition & 1 deletion content/docs/protocol/objectql/state-machine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ transitions: {
- **Seed writes are exempt** (#3433). Curated seed data — package bootstrap fixtures, marketplace templates, per-org replay, all loaded by `SeedLoaderService` — is a snapshot of established facts, not a record walking its lifecycle, so it bypasses the `state_machine` rule entirely: a seed may be born mid-lifecycle (a `completed` project, a `closed_won` opportunity) and neither `initialStates` (insert) nor `transitions` (update) is enforced. Every *other* validation still runs, so a seed must still satisfy field shape, `format`, `script`, and the rest. `os lint` warns when a seeded value is not a state the machine declares, so a typo is still caught before boot.
- **A "historical" data import is exempt too** (#3479). Migrating established facts — a batch of already-`closed` tickets, `closed_won` deals — is the same "snapshot, not a lifecycle event" situation. Set `treatAsHistorical: true` on the import request (default **off**) and the runner puts `skipStateMachine` on the write context, so `initialStates` doesn't reject those mid-lifecycle rows. A normal import leaves it off and still walks the FSM — the strict behavior is the default, so the exemption is always an explicit opt-in.
- **`treatAsHistorical` also preserves the original audit timeline** (#3493) — **on the rows an import UPDATES** (#6640). Skipping the FSM is only half of migrating established facts; the other half is keeping *when* they happened and *who* did them. Under the same flag the write context also carries `preserveAudit`, which (1) makes `updated_at` / `updated_by` **client-preferred** — a supplied historical last-modified survives instead of being stamped with the import instant — and (2) admits a **whitelist** through the static-`readonly` write strip: the audit/timestamp family plus author-declared business `readonly` fields (`closed_at`, `resolved_by`, …) — but never the record's own primary key (`id`), which is the address of the write rather than a fact being restored (#8215). Platform-managed `system` columns outside that family (`organization_id` and other tenancy/generated columns) stay stripped — a historical import reinstates facts, it does not forge tenancy. Like the FSM exemption this is opt-in: a normal write still auto-stamps `updated_at`/`updated_by` and strips `readonly` exactly as before, and permissions / RLS / field-level security are unchanged.
- **…but a historical `upsert` still drops those columns from the rows it CREATES** (#6640). `preserveAudit` is an **UPDATE-path exemption and nothing else reads it**, because the two write paths run two different strips: UPDATE is stripped inside the engine (`stripReadonlyFields`), which consults `preserveAudit`; CREATE is stripped earlier, at the DataProtocol ingress (`stripReadonlyForInsert`, #3043) that every REST-import create travels, and that one's only exemption is `context.isSystem`. So a single `treatAsHistorical` upsert keeps `closed_at` on the rows it **matches** and strips it — together with a supplied `created_at` / `updated_at`, which the injected audit columns also declare `readonly` — from the rows it **inserts**. The asymmetry is deliberate, not an oversight: `treatAsHistorical` arrives on an ordinary (non-system) import request, so honouring it on create would let any caller seed the approval/status columns that create-side strip exists to protect. The ignored request is at least no longer silent — the server logs a `WARN` naming the object, the stripped fields and this UPDATE-only rule — but the strip still applies. **To replay archival read-only facts on the rows an import creates, write from a system context** (`isSystem`). Full rule and rationale: [Security & Access Control](/docs/protocol/objectql/security).
- **…but a historical `upsert` still drops those columns from the rows it CREATES** (#6640). `preserveAudit` is an **UPDATE-path exemption and nothing else reads it**: both write paths run the same in-engine strip (`stripReadonlyFields`, #14147), but only the UPDATE call site passes `preserveAudit` — the create side is given no such flag and keeps `context.isSystem` as its only exemption. So a single `treatAsHistorical` upsert keeps `closed_at` on the rows it **matches** and strips it — together with a supplied `created_at` / `updated_at`, which the injected audit columns also declare `readonly` — from the rows it **inserts**. The asymmetry is deliberate, not an oversight: `treatAsHistorical` arrives on an ordinary (non-system) import request, so honouring it on create would let any caller seed the approval/status columns that create-side strip exists to protect. The ignored request is at least no longer silent — the server logs a `WARN` naming the object, the stripped fields and this UPDATE-only rule — but the strip still applies. **To replay archival read-only facts on the rows an import creates, write from a system context** (`isSystem`). Full rule and rationale: [Security & Access Control](/docs/protocol/objectql/security).
- **Undoing a historical import is symmetric** (#3549 / #3556). The import undo (`POST /api/v1/data/import/jobs/:jobId/undo`) logically rolls back a finished job — deleting the rows it created and restoring the captured pre-import snapshot on the rows it updated. That restore write now carries `preserveAudit` too, but **only** when the job was flagged `treatAsHistorical`, so the snapshotted `updated_at` / `updated_by` and business `readonly` fields (`closed_at`, …) are reinstated verbatim instead of being re-stamped to the undo instant. The undo is unaffected by the create-side carve-out above: it only ever *deletes* the rows the import created and *updates* the rows it touched, so every write it makes is on the path where the exemption is real. Without it the undo would silently overwrite the very timeline the historical import preserved; a normal (non-historical) import's undo keeps the default stamp/strip.

### Conditional transitions
Expand Down
8 changes: 4 additions & 4 deletions docs/qa/platform-checklist/areas/records-forms.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@
{
"clause": "clone (POST /data/:object/:id/clone, gated by enable.clone default-on) returns 201 with a NEW id and the source's field VALUES copied, but engine-owned columns (id, audit, autonumber, formula, summary) and readonly columns (e.g. approval_status) RE-DERIVED not carried, and the clone is owned by the CLONER — not the source's owner",
"oracle": "api",
"verify": "the 201 result carries {id (new ≠ sourceId), sourceId, record}; field-by-field diff shows business values copied and system/readonly columns re-derived (#3043 CLONE_STRIP_FIELDS + stripReadonlyForInsert); owner_id resolves to the signed-in cloner (the clone is a create in the caller's context — packages/metadata-protocol/src/protocol.ts cloneData)",
"verify": "the 201 result carries {id (new ≠ sourceId), sourceId, record}; field-by-field diff shows business values copied and system/readonly columns re-derived (#3043 CLONE_STRIP_FIELDS + the engine's create-side static-readonly strip, #14147); owner_id resolves to the signed-in cloner (the clone is a create in the caller's context — packages/metadata-protocol/src/protocol.ts cloneData)",
"evidence": "clone response + source-vs-clone field diff + owner_id read"
},
{
Expand All @@ -160,7 +160,7 @@
"dogfood-verification skill §3",
"examples/app-showcase/src/data/objects/account.object.ts (requiredness + format/conditional validations)",
"packages/runtime/src/route-ledger.ts (/data CRUD routes)",
"packages/rest/src/rest-server.ts#registerDataActionEndpoints (POST /data/:object/:id/clone → registerDataActionEndpoints) + packages/metadata-protocol/src/protocol.ts#cloneData (cloneData: enable.clone gate, findOne-in-caller-context, CLONE_STRIP_FIELDS, stripReadonlyForInsert)",
"packages/rest/src/rest-server.ts#registerDataActionEndpoints (POST /data/:object/:id/clone → registerDataActionEndpoints) + packages/metadata-protocol/src/protocol.ts#cloneData (cloneData: enable.clone gate, findOne-in-caller-context, CLONE_STRIP_FIELDS; the static-readonly strip is engine.insert's since #14147)",
"packages/rest/src/rest-route-ledger.ts (POST /api/v1/data/:object/:id/clone, client data.clone)",
"objectui: e2e/live/record-history-display.spec.ts",
"cross-ref: the inline-edit atomic two-surface behavior (ONE Save bar / ONE PATCH carrying exactly the changed keys + ifMatch) is folded into records-forms.concurrent-edit-conflict, not here"
Expand Down Expand Up @@ -3860,7 +3860,7 @@
"evidence": "both responses + the unchanged count"
},
{
"clause": "overrides WIN over copied business values — the override name lands on the clone — but cannot FORGE protected columns: stripReadonlyForInsert runs AFTER Object.assign(data, overrides) (protocol.ts), so a readonly/engine-owned key smuggled through overrides is dropped and re-derived, same as #3043's carried-over case",
"clause": "overrides WIN over copied business values — the override name lands on the clone — but cannot FORGE protected columns: the create-side static-readonly strip runs AFTER Object.assign(data, overrides) — the overrides are applied in protocol.ts cloneData and judged in engine.insert (#14147) — so a readonly/engine-owned key smuggled through overrides is dropped and re-derived, same as #3043's carried-over case",
"oracle": "api",
"verify": "the clone's re-read shows the override name AND the forged key re-derived (defaultValue / fresh audit stamp), not the smuggled value",
"evidence": "the overrides payload + the clone re-read diff"
Expand Down Expand Up @@ -3898,7 +3898,7 @@
"ref": "packages/metadata-protocol — search-clone-schema-conformance.test.ts (parses the real cloneData producer); packages/rest — search-clone-route-schema-conformance.test.ts (drives the live mount); named on the ledger row rest-route-ledger.ts"
},
"source": [
"packages/metadata-protocol/src/protocol.ts#cloneData (cloneData: registration gate #3770, CLONE_DISABLED, findOne-in-context, CLONE_STRIP_FIELDS + system/autonumber/formula/summary strip, overrides, stripReadonlyForInsert #3043, omitInternalFieldsFromWriteResponse #7823)",
"packages/metadata-protocol/src/protocol.ts#cloneData (cloneData: registration gate #3770, CLONE_DISABLED, findOne-in-context, CLONE_STRIP_FIELDS + system/autonumber/formula/summary strip, overrides, omitInternalFieldsFromWriteResponse #7823; the static-readonly strip moved to engine.insert #14147)",
"packages/rest/src/rest-route-ledger.ts (POST /api/v1/data/:object/:id/clone, client data.clone, bare-201 note #11924)",
"objectui: packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx (Studio authors the enable.clone opt-out switch)",
"cross-ref: the clone HAPPY path + engine-column re-derivation + RLS-gated 404 are crud-roundtrip clauses 7-8 — this item drives only the contract edges that item does not, and deliberately re-states none of its oracles"
Expand Down
24 changes: 20 additions & 4 deletions examples/app-todo/test/task-completion-trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ async function bootTodoKernel(): Promise<{
// so without this line the completion stamp would not exist here and every
// completion below would be refused — which is exactly the bug, and exactly
// why the old version of this file seeded `completed_date` on CREATE.
// [#14147] That seed is no longer available as a workaround either: a
// non-system create has a `readonly` column stripped just as an update does,
// so binding the hook is the only way this harness can complete a task.
// `task-recurrence.test.ts` bound it for the same reason.
objectql.bindHooks([taskHook], { packageId: 'app:com.example.todo' });

for (const flow of allFlows) automation.registerFlow(flow.name, flow);
Expand Down Expand Up @@ -227,15 +231,27 @@ describe('#6882 — app-todo `task_completion` is armed, not dead', () => {
* update status+completed_date (user ctx): REJECTED -> Completed date is required when status is Completed
* update status only (user ctx): REJECTED -> Completed date is required when status is Completed
* update status+completed_date (isSystem): OK
* insert already-completed: OK
* insert already-completed (user ctx): OK
*
* — i.e. every escape was a NON-user path, and `completeTask` always failed.
*
* ⚠️ [#14147] The FOURTH row of that table is history, not a live escape. It
* held because the create path was exempt from the static-`readonly` strip; the
* maintainer ruling of 2026-09-03 overturned that exemption, so a non-system
* create that seeds `completed_date` now has it stripped exactly as an update
* does, and an already-completed insert from a user context is refused by the
* same rule. The table is left as measured — it is dated evidence of the
* defect, and rewriting it would be rewriting the measurement — but nothing
* below may be read as saying a create may still seed a server-owned column.
* Seeding one at create time is a SYSTEM act (`context.isSystem`,
* `runAs: 'system'`, a system hook or a seed).
*
* The repair is the server owning the column: `task.hook.ts` stamps it on the
* transition, and the strip lets a hook's write through because it only
* deletes a key that still holds the *caller's own* value (#2948/#5591). The
* assertions below are written against that seam rather than against the
* message, so they stay meaningful if the wording changes.
* deletes a key that still holds the *caller's own* value (#2948/#5591) — the
* same guard on both write paths now. The assertions below are written against
* that seam rather than against the message, so they stay meaningful if the
* wording changes.
*/
describe('#7036 — completing a task is possible for a normal user', () => {
const ctx = { context: { userId: 'u_todo' } };
Expand Down
Loading
Loading