Skip to content

Commit e78c95c

Browse files
committed
docs(spec,rest,objectql,mcp,qa,scripts): the deleted ingress strip is no longer stated as live in schema text, code notes, test titles, the census gate's row annotations or the authz matrix
Patch round R3 on the contract re-review (5549250610), items 1-3 and 5. Prose only; no behaviour change, no test deleted or skipped. - N2 (a)-(e): the `preserveAudit`, `CreateDataResponseSchema.droppedFields`, `CreateManyDataResponseSchema.droppedFields` and `BatchOperationResultSchema.droppedFields` `.describe()` strings and the `CloneDataResponseSchema` TSDoc now state the ruled state: the static `readonly` strip runs inside `engine.insert`, after `beforeInsert`, `isSystem`-gated; the DataProtocol ingress copy is deleted. The generated `content/docs/references/**` rows follow in the next commit via `check:generated --fix`. - N2 (f)-(k): `rest-server.ts` batch-route note keeps the routing reason on what the ingress still owns (object-existence gate, #7823 response strip, `droppedFields` relay) and attributes the platform-object carve-out to the engine, where it lives (`staticReadonlyInsertSubject`); the #3431 header note; `rule-validator.ts`'s second-consumer sentence names `staticReadonlyInsertSubject`; the mcp stdio bridge's divergence list drops the readonly strip and says why it is closed; two test comments/titles in `engine-autonumber-runtime-owned.test.ts` and one title in `rest-dropped-fields.test.ts`. - N3: the seven `why` rows in `check-system-context-census.mjs` re-derived against the head table (24->23 x2, 22->21 x2, 25->24, 34->33, 60->59). The gate never parses them. - N4: the authz matrix row states the two mechanisms - a system context for identity provisioning; the `sys_`/`managedBy` carve-out for the metadata repository's `sys_metadata_history.recorded_by` provenance row - and re-derives "event-log cursors" by name as `sys_metadata_history.event_seq` (same row, same mechanism). Issue-id multiset of the row is byte-identical for the prose-id ledger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent 66c580b commit e78c95c

10 files changed

Lines changed: 66 additions & 48 deletions

File tree

‎packages/mcp/src/stdio-data-bridge.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@
4747
*
4848
* `callData` prefers the `protocol` service (metadata-protocol) and falls back
4949
* to the engine; this bridge is engine-only. So the HTTP tools additionally get
50-
* that layer's ingress `readonly` strip, its existence probes, its spec-shaped
51-
* receipts and `expand`/`select`. None of those is the authorization boundary
50+
* that layer's existence probes, its spec-shaped receipts and `expand`/`select`.
51+
* (That layer's create-side `readonly` strip used to head this list; since the
52+
* maintainer ruling of 2026-09-03 — option C, #14147 — the static `readonly`
53+
* strip runs inside `engine.insert` for every non-system caller and the
54+
* ingress copy is deleted, so on that point the two transports no longer
55+
* differ.) None of those is the authorization boundary
5256
* — every call here still passes the engine's CRUD/FLS/RLS — but the two
5357
* transports should not differ at all, and unifying them behind one
5458
* transport-neutral data seam is filed as follow-up work rather than forked

‎packages/objectql/src/engine-autonumber-runtime-owned.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -608,9 +608,12 @@ describe('#5628 — a `readonly: true` autonumber keeps the #5503 exemption set'
608608
});
609609

610610
it('keeps a legacy number for a `preserveAudit` historical import THROUGH THE INGRESS (#3493)', async () => {
611-
// The regression this whole describe exists for: the ingress strip has no
612-
// `preserveAudit` exemption, so if it acted on the flag the value would be
613-
// gone before the engine's whitelist ran.
611+
// The regression this whole describe exists for: the static strip has no
612+
// `preserveAudit` exemption — at the ingress when this was written, inside
613+
// `engine.insert` since ruling C — so if it acted on the flagged autonumber
614+
// the value would be gone before the runtime-owned whitelist could keep it.
615+
// It does not: the static pass runs over `staticReadonlyInsertSubject`,
616+
// with runtime-owned types removed, after the runtime-owned pass.
614617
const created = await rig.protocol.createData({
615618
object: 'an_invoice',
616619
data: { name: 'legacy', invoice_number: 'LEGACY-0007' },
@@ -628,7 +631,7 @@ describe('#5628 — a `readonly: true` autonumber keeps the #5503 exemption set'
628631
expect(created.record.invoice_number).toBe('INV-000042');
629632
});
630633

631-
it('an author-declared `readonly` field of an ORDINARY type is still stripped at the ingress', async () => {
634+
it('an author-declared `readonly` field of an ORDINARY type is still stripped on a create through the ingress — inside `engine.insert` since ruling C', async () => {
632635
// The #3043 strip keeps its full width — only runtime-owned types moved.
633636
rig.engine.registry.registerObject({
634637
name: 'an_case',

‎packages/objectql/src/validation/rule-validator.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -997,11 +997,15 @@ export function stripReadonlyWhenFieldsMulti(
997997
*
998998
* The set itself now lives in `@objectstack/spec` (`RUNTIME_OWNED_FIELD_TYPES`,
999999
* #5628) — the protocol's one statement of the ownership — because a SECOND
1000-
* consumer needs it: the DataProtocol create ingress, whose `readonly` strip
1001-
* carries a NARROWER exemption set than this module's (no `preserveAudit`), and
1002-
* which therefore has to recognise these types to stay out of their way. A
1003-
* literal copied over there is the drift `AUDIT_TIMELINE_FIELDS` below stopped
1004-
* paying for. This module keeps the reasoning; the membership is imported.
1000+
* consumer needs it: the create-side static-`readonly` strip, whose exemption
1001+
* set is NARROWER than this helper's (no `preserveAudit`) and which therefore
1002+
* has to recognise these types to stay out of their way. When #5628 landed
1003+
* that consumer was the DataProtocol create ingress in another package; since
1004+
* the maintainer ruling of 2026-09-03 (option C, #14147) it is
1005+
* {@link staticReadonlyInsertSubject} in this module, feeding `engine.insert`,
1006+
* and the ingress copy is deleted. A literal copied over there was the drift
1007+
* `AUDIT_TIMELINE_FIELDS` below stopped paying for; the membership stays
1008+
* imported from the spec. This module keeps the reasoning.
10051009
*/
10061010

10071011
/**

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,9 +279,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
279279
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
280280
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },
281281
{ id: 'readonly-static-write', summary: 'static `readonly: true` stripped from non-system UPDATE (#2948 / #3003) AND INSERT (#3043 at the ingress; in-engine for every caller since the 2026-09-03 ruling) payloads — neither a direct PATCH nor a direct POST can forge approval/status/amount columns the UI never renders', state: 'enforced',
282-
enforcement: 'UPDATE: objectql/engine.ts stripReadonlyFields on the single-id + multi-row paths (#2948, caller-supplied VALUES only — the entry snapshot carries the caller payload, so a server stamp survives whether the hook ADDED the key or OVERWROTE one the caller also sent, #5591). INSERT: objectql/engine.ts runs the SAME stripReadonlyFields inside engine.insert (maintainer ruling 2026-09-03, option C — one semantics, one enforcement point; the metadata-protocol ingress copy that used to cover only the DataProtocol faces while a direct engine.insert caller bypassed it is deleted), after the beforeInsert hooks and before validation, over staticReadonlyInsertSubject (runtime-owned types keep their own pass, platform objects their own 403 guards); the field re-derives its defaultValue. isSystem exempt on both; internal writers that seed readonly columns on create (identity provisioning, provenance, event-log cursors) do so under a system context; symmetric with the readonlyWhen strip',
282+
enforcement: 'UPDATE: objectql/engine.ts stripReadonlyFields on the single-id + multi-row paths (#2948, caller-supplied VALUES only — the entry snapshot carries the caller payload, so a server stamp survives whether the hook ADDED the key or OVERWROTE one the caller also sent, #5591). INSERT: objectql/engine.ts runs the SAME stripReadonlyFields inside engine.insert (maintainer ruling 2026-09-03, option C — one semantics, one enforcement point; the metadata-protocol ingress copy that used to cover only the DataProtocol faces while a direct engine.insert caller bypassed it is deleted), after the beforeInsert hooks and before validation, over staticReadonlyInsertSubject (runtime-owned types keep their own pass, platform objects their own 403 guards); the field re-derives its defaultValue. isSystem exempt on both. An internal writer that seeds a readonly column on create does so by one of exactly two mechanisms: (1) a system context — identity provisioning (plugin-auth objectql-adapter.ts wraps its engine in withSystemContext); (2) the platform-object carve-out in staticReadonlyInsertSubject (rule-validator.ts) — a sys_-prefixed or managedBy object is outside the strip subject, so the metadata repository (sys-metadata-repository.ts) seeds sys_metadata_history provenance (recorded_by, readonly lookup) and its event-log cursor (event_seq, readonly number) under the CALLER context, and those columns are policed by the object guards of that platform object rather than swallowed by the strip — as is every other platform-object seeder; symmetric with the readonlyWhen strip',
283283
proof: 'showcase-static-readonly.dogfood.test.ts',
284-
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. #3043 is the INSERT face: the same non-admin could skip the draft entirely and POST a record already `approval_status:"approved"` — a step SHORTER than #3003, and one the UPDATE strip never reached. It was first enforced at the DATA-WRITE INGRESS (not the engine) so it covered every external caller — REST, the GraphQL/MCP dispatcher, bulk import — without stripping internal writers; the maintainer ruling of 2026-09-03 (option C) moved it INTO the engine, so a direct engine.insert caller is covered too and a writer that seeds a readonly column on create (identity provisioning, provenance, event-log cursors) does so under a system context. The strip is SILENT on both paths (HTTP 2xx, forged value dropped; a stripped INSERT field falls back to its defaultValue). `readonlyWhen` stays INSERT-exempt (a conditional lock needs a prior record). System-context writes (import, seed replay, migration) still seed readonly columns. Engine proof in objectql engine-insert-static-readonly-strip.test.ts (forge stripped against a real ObjectQL, default re-seeded, system context allowed, hook stamps survive, strict refuses); delegation proof in metadata-protocol protocol.readonly-insert.test.ts (every create face forwards whole and surfaces the engine verdict as droppedFields where its contract carries one).' },
284+
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. #3043 is the INSERT face: the same non-admin could skip the draft entirely and POST a record already `approval_status:"approved"` — a step SHORTER than #3003, and one the UPDATE strip never reached. It was first enforced at the DATA-WRITE INGRESS (not the engine) so it covered every external caller — REST, the GraphQL/MCP dispatcher, bulk import — without stripping internal writers; the maintainer ruling of 2026-09-03 (option C) moved it INTO the engine, so a direct engine.insert caller is covered too. A writer that seeds a readonly column on create does so under a system context (identity provisioning) or, for a platform object (sys_-prefixed or managedBy), under the carve-out in staticReadonlyInsertSubject that leaves such objects to their own guards — the metadata repository writing sys_metadata_history.recorded_by provenance and its event_seq event-log cursor under the caller context is that second mechanism, not a system context. The strip is SILENT on both paths (HTTP 2xx, forged value dropped; a stripped INSERT field falls back to its defaultValue). `readonlyWhen` stays INSERT-exempt (a conditional lock needs a prior record). System-context writes (import, seed replay, migration) still seed readonly columns. Engine proof in objectql engine-insert-static-readonly-strip.test.ts (forge stripped against a real ObjectQL, default re-seeded, system context allowed, hook stamps survive, strict refuses); delegation proof in metadata-protocol protocol.readonly-insert.test.ts (every create face forwards whole and surfaces the engine verdict as droppedFields where its contract carries one).' },
285285

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

‎packages/rest/src/rest-dropped-fields.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ describe('PATCH /data/:object/:id — X-ObjectStack-Dropped-Fields (#3431)', ()
9494
});
9595

9696
describe('POST /data/:object — X-ObjectStack-Dropped-Fields on create (#3431)', () => {
97-
it('sets the header (status still 201) when the create ingress stripped a readonly field', async () => {
97+
it('sets the header (status still 201) when `createData` relays a readonly field dropped by the create-side strip in `engine.insert`', async () => {
9898
const createData = vi.fn().mockResolvedValue({
9999
object: 'approval_case', id: 'rec-1', record: { id: 'rec-1', title: 'A' }, droppedFields: DROPPED,
100100
});

‎packages/rest/src/rest-server.ts‎

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8005,9 +8005,10 @@ export class RestServer {
80058005
...(environmentId ? { environmentId } : {}),
80068006
...(context ? { context } : {}),
80078007
} as any);
8008-
// [#3431] Advertise fields the #3043 create ingress strip
8009-
// dropped via the response header (body also carries
8010-
// `droppedFields`). Status stays 201.
8008+
// [#3431] Advertise fields the engine's create-side static-
8009+
// `readonly` strip dropped (`engine.insert`, relayed by
8010+
// `createData` as `droppedFields`) via the response header
8011+
// (body also carries `droppedFields`). Status stays 201.
80118012
applyDroppedFieldsHeader(res, result);
80128013
res.status(201).json(result);
80138014
} catch (error: any) {
@@ -12354,22 +12355,25 @@ export class RestServer {
1235412355
if (op.action === 'create') {
1235512356
// [#3835] Go through the protocol's create ingress —
1235612357
// the SAME one `POST /data/:object` uses — rather than
12357-
// calling `ql.insert` directly. The engine's INSERT path
12358-
// is static-`readonly`-exempt by design (#3413), so the
12359-
// #3043 strip that stops a non-system caller from seeding
12360-
// a read-only column lives at that ingress. Bypassing it
12361-
// here made `readonly` mean two different things on two
12362-
// create paths: rejected on the single route, written
12363-
// through the batch. `createData` also owns the platform-
12364-
// object carve-out (a `sys_`/`managedBy` object's own
12365-
// guard must REJECT a forged value, not silently swallow
12366-
// it), which is why this routes to the ingress instead of
12367-
// re-implementing the strip here — one create ingress,
12368-
// and a future change to its policy covers the batch for
12369-
// free. `trxCtx` carries the caller's context (including
12370-
// `isSystem`) plus the open transaction, so the strip
12371-
// decides exactly as it does on the single route and the
12372-
// insert still joins this transaction.
12358+
// calling `ql.insert` directly. When this was written the
12359+
// #3043 static-`readonly` strip lived at that ingress and
12360+
// a direct `ql.insert` bypassed it, so `readonly` meant
12361+
// two different things on two create paths. Since the
12362+
// maintainer ruling of 2026-09-03 (option C, #14147) the
12363+
// strip runs inside `engine.insert` for every non-system
12364+
// caller, so both routes are stripped identically, and the
12365+
// platform-object carve-out (a `sys_`/`managedBy` object's
12366+
// own guard must REJECT a forged value, not silently
12367+
// swallow it) is the engine's as well. The routing stands
12368+
// on what the ingress still owns: the #3770 object-
12369+
// existence gate, the #7823 `internal: true` response
12370+
// strip and the `droppedFields` relay — one create
12371+
// ingress, one response contract, and a future change to
12372+
// its policy covers the batch for free. `trxCtx` carries
12373+
// the caller's context (including `isSystem`) plus the
12374+
// open transaction, so the engine's strip decides exactly
12375+
// as it does on the single route and the insert still
12376+
// joins this transaction.
1237312377
const created: any = await p.createData({ object: op.object, data, context: trxCtx } as any);
1237412378
for (const e of (created?.droppedFields ?? []) as DroppedFieldsEvent[]) {
1237512379
dropped.push({ ...e, index });

‎packages/spec/src/api/batch.zod.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export const BatchOperationResultSchema = lazySchema(() => z.object({
206206
droppedFields: z.array(DroppedFieldsEventSchema).optional().describe(
207207
'Write-observability: caller-supplied fields LEGALLY stripped from ' +
208208
'THIS row before it was written — static `readonly` / TRUE `readonlyWhen`' +
209-
' on update, or the create-ingress strip. Per-row because a batch can drop ' +
209+
' on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop ' +
210210
'different fields on different rows (`readonlyWhen` is record-state-dependent). Present ' +
211211
'ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). ' +
212212
'A single response header cannot express per-row drops, so this body field is the ' +

‎packages/spec/src/api/protocol.zod.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1973,7 +1973,8 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({
19731973
droppedFields: z.array(DroppedFieldsEventSchema).optional().describe(
19741974
'Write-observability: caller-supplied fields that were LEGALLY stripped ' +
19751975
'before the record was written — a non-system create cannot seed a static `readonly` ' +
1976-
'column (ingress strip), so those keys are dropped and the field re-derives its ' +
1976+
'column (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, ' +
1977+
'`isSystem`-gated), so those keys are dropped and the field re-derives its ' +
19771978
'default. Present ONLY when ≥1 field was dropped; the create still succeeded without ' +
19781979
'them (status/success semantics unchanged). REST additionally surfaces this as the ' +
19791980
'`X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the ' +
@@ -1993,9 +1994,11 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({
19931994
* stable and server-produced): `cloneData` builds exactly
19941995
* `{ object, id, sourceId, record }` — the structural sibling of
19951996
* {@link CreateDataResponseSchema} plus `sourceId`. A clone IS a create (the
1996-
* copy runs the insert path: engine-owned columns re-derived, the #3043
1997-
* readonly ingress strip applied, internal fields omitted from the response,
1998-
* #7823) — but unlike `createData` the producer emits no `droppedFields`
1997+
* copy runs the insert path: engine-owned columns re-derived, the static
1998+
* `readonly` strip applied inside `engine.insert` for a non-system caller —
1999+
* the 2026-09-03 ruling, #14147; the #3043 ingress copy is deleted — and
2000+
* internal fields omitted from the response, #7823) — but unlike `createData`
2001+
* the producer emits no `droppedFields`
19992002
* member, so none is declared: a key the producer never writes would be a
20002003
* promise conformance cannot measure.
20012004
*/
@@ -2311,8 +2314,8 @@ export const CreateManyDataResponseSchema = lazySchema(() => z.object({
23112314
records: z.array(z.record(z.string(), z.unknown())).describe('Created records'),
23122315
count: z.number().describe('Number of records created'),
23132316
droppedFields: z.array(DroppedFieldsEventSchema).optional().describe(
2314-
'Write-observability: caller-supplied `readonly` fields the ' +
2315-
'create-ingress strip removed before the rows were written. AGGREGATED across the batch ' +
2317+
'Write-observability: caller-supplied `readonly` fields the in-engine create-side ' +
2318+
'strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch ' +
23162319
'(one event per object/reason with the union of dropped field names) rather than per-row, ' +
23172320
'because the insert-time strip is static-`readonly` only — schema-uniform, so every row ' +
23182321
'drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded ' +

0 commit comments

Comments
 (0)