From ce47ff58f672a96e46f4d4f7ed03129030b6ddcb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 00:01:33 +0000 Subject: [PATCH 1/3] fix(metadata-protocol): order the ADR-0067 commit timeline by instant, not by the weekday name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `created_at` is an engine-injected audit column: not in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. The live SQL dialects therefore hand it out of the record read door as a JS `Date` while the SQLite family hands out canonical ISO-Z text. Both ADR-0067 commit-timeline consumers compared `String(created_at)`, and `String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` — the LEADING token is the weekday NAME, so lexicographic order over those strings is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`. Unrelated to chronology, and stable across the whole set, so it is wrong on every run and wrong the same way. - `listCommits` returned the timeline in weekday-name order while claiming newest-first; its own comment stated the assumption ("sort by the ISO timestamp") and it was false on the production default driver. - `rollbackToPackageCommit` both consumed that ordering and re-derived the same comparison itself, so neither site could correct the other: it reverted `apply` commits OLDER than the target and skipped the newer ones it exists to undo. Both sites now compare canonical absolute instants through `compareAuditInstants`, a sibling of the `canonicalVersionInstant` helper #13382 landed one seam over in this same file. The canonicalisation is reused; the ordering is new, because `versionTokensAgree` answers equality between client-supplied version tokens and an ordering question needs `<`/`>`. When either side does not denote an instant the two are compared verbatim exactly as before, so only instant-bearing pairs change verdict. The pin drives a hand-made `Date` — `@objectstack/metadata-protocol` has no driver dependency and must not grow one — over four consecutive days, the smallest fixture for which no timezone alignment can make the old weekday comparison agree with chronology. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --- .changeset/commit-timeline-instant-order.md | 38 +++ ...ocol.commit-timeline-instant-order.test.ts | 243 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 99 ++++++- 3 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 .changeset/commit-timeline-instant-order.md create mode 100644 packages/metadata-protocol/src/protocol.commit-timeline-instant-order.test.ts diff --git a/.changeset/commit-timeline-instant-order.md b/.changeset/commit-timeline-instant-order.md new file mode 100644 index 0000000000..f694258e6e --- /dev/null +++ b/.changeset/commit-timeline-instant-order.md @@ -0,0 +1,38 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): order the ADR-0067 commit timeline by INSTANT, so `rollbackToPackageCommit` stops planning off the weekday name (#13995) + +`created_at` is an engine-injected audit column: it is not in `datetimeFields`, +and `SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. So the +live SQL dialects hand it out of the record read door as a JS `Date` while the +SQLite family hands out canonical ISO-Z text. Both ADR-0067 commit-timeline +consumers compared `String(created_at)` — and `String(aDate)` is +`"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"`, whose LEADING token +is the weekday NAME. Lexicographic order over those strings is +`Fri < Mon < Sat < Sun < Thu < Tue < Wed`: unrelated to chronology, stable +across the whole set, and therefore wrong on every run and wrong the same way — +there was never an "it worked once" to warn anyone. + +- `listCommits` returned the package timeline in weekday-name order while + claiming newest-first. Its own comment stated the assumption in as many words + ("sort by the ISO timestamp") and it was false on the production default + driver. +- `rollbackToPackageCommit` both CONSUMED that ordering and re-derived the same + comparison itself, so neither site could correct the other. On Postgres and + MySQL it reverted `apply` commits OLDER than the target and skipped the newer + ones it exists to undo — a destructive operation planning off a wrong + predicate. + +Both sites now compare canonical absolute instants, through a sibling of the +`canonicalVersionInstant` helper #13382 landed one seam over in this same file +for the OCC `updated_at` comparison. The canonicalisation is reused; the +ordering is new, because `versionTokensAgree` answers equality between two +client-supplied version tokens and an ordering question needs `<`/`>`. When +either side does not denote an instant the two are compared verbatim exactly as +before, so the only verdicts that change are the pairs that denote one. + +On SQLite and the memory driver both sides were already canonical ISO-Z text and +lexicographic order equalled chronological order, so nothing changes there — +which is why every test these sites had stayed green through the defect. diff --git a/packages/metadata-protocol/src/protocol.commit-timeline-instant-order.test.ts b/packages/metadata-protocol/src/protocol.commit-timeline-instant-order.test.ts new file mode 100644 index 0000000000..736716d74a --- /dev/null +++ b/packages/metadata-protocol/src/protocol.commit-timeline-instant-order.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#13995] The ADR-0067 package commit timeline sorted by WEEKDAY NAME on the +// production default drivers, and `rollbackToPackageCommit` planned its reverts +// off the same comparison. +// +// --------------------------------------------------------------------------- +// The defect +// --------------------------------------------------------------------------- +// `created_at` is an engine-injected audit column: it is not in `datetimeFields` +// and `SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`, so +// the live SQL dialects hand it out of the record read door as a JS `Date` while +// the SQLite family hands out canonical ISO-Z text. Pinned one layer down by +// `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`. +// +// Both timeline consumers in `protocol.ts` compared `String(created_at)`: +// +// listCommits mapped.sort(… String(b.createdAt).localeCompare(…)) +// rollbackToPackageCommit all.filter(c => String(c.createdAt) > targetCreatedAt) +// +// `String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` +// — the LEADING token is the weekday name — so lexicographic order over those +// strings is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`, unrelated to chronology. +// The order is stable across the whole set, so the failure is systematic and +// identical on every run: there is never an "it worked once" to warn anyone. +// +// The two sites reinforce rather than backstop each other. `listCommits` returns +// a mis-ordered timeline while claiming newest-first, and +// `rollbackToPackageCommit` both CONSUMES that ordering and re-derives the same +// comparison itself — so it reverts `apply` commits OLDER than the target and +// skips the newer ones it exists to undo. That is a destructive operation +// planning off a wrong predicate. +// +// --------------------------------------------------------------------------- +// Why the fixture is FOUR consecutive days, and why that makes this pin +// timezone-independent +// --------------------------------------------------------------------------- +// `String(aDate)` renders the weekday in the PROCESS timezone, so which weekday +// name each instant carries depends on `TZ`. Rather than pin `TZ` (which Node +// caches per platform) the fixture is chosen so the old comparison is wrong in +// EVERY alignment: map each weekday to its rank in the lexicographic order +// (`Fri`=0, `Mon`=1, `Sat`=2, `Sun`=3, `Thu`=4, `Tue`=5, `Wed`=6) and read off +// the seven windows of four consecutive weekdays — +// +// Sun Mon Tue Wed -> 3 1 5 6 Thu Fri Sat Sun -> 4 0 2 3 +// Mon Tue Wed Thu -> 1 5 6 4 Fri Sat Sun Mon -> 0 2 3 1 +// Tue Wed Thu Fri -> 5 6 4 0 Sat Sun Mon Tue -> 2 3 1 5 +// Wed Thu Fri Sat -> 6 4 0 2 +// +// — not one of the seven is monotonic, so no timezone can make the old sort +// agree with chronology. (Three consecutive days is NOT enough: `Mon Tue Wed` +// and `Fri Sat Sun` are both increasing.) The same table settles the planner: +// with the target at the second day, the old predicate selects `{1,3,4}`, +// `{3}`, `{}`, `{1}`, `{1,3,4}`, `{3}`, `{4}` across the seven alignments and +// never the correct `{3,4}`. Every instant is 24h apart at 12:00Z, far enough +// from local midnight that no offset or DST step can collapse two onto one +// local day. `assertTheFixtureDiscriminates` below asserts the property +// mechanically rather than trusting this comment. +// +// --------------------------------------------------------------------------- +// Why the `Date` is hand-made rather than read off a driver +// --------------------------------------------------------------------------- +// `@objectstack/metadata-protocol` has no driver dependency and must not grow +// one — the layering runs the other way. This is the same split +// `sql-driver-13567-audit-stamp-materialisation.test.ts` documents for the OCC +// seam: the driver package pins WHAT the dialects materialise, and the consumer +// package pins that it is correct FOR that shape. +// +// --------------------------------------------------------------------------- +// Reverse verification, direction predicted BEFORE running +// --------------------------------------------------------------------------- +// Ordinary red, and separated by site so the ablation says which one broke: +// +// * Restore `String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''))` +// in `listCommits` -> the two ordering cases go red, the planner case stays +// GREEN (a `filter` preserves order but the selected SET does not depend on +// it), and the ISO-text cases stay green. +// * Restore `String(c.createdAt ?? '') > String(target.created_at ?? '')` in +// `rollbackToPackageCommit` -> the planner case goes red on the SET, and the +// ordering cases stay green. +// +// The ISO-text half stays green in both directions on purpose: it is what shows +// the repair is the `Date` shape and not a blanket rewrite of the comparison. + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; + +/** Four consecutive days plus one more, at 12:00Z. See the header for why. */ +const DAY_1 = '2026-08-30T12:00:00.000Z'; +const DAY_2 = '2026-08-31T12:00:00.000Z'; +const DAY_3 = '2026-09-01T12:00:00.000Z'; +const DAY_4 = '2026-09-02T12:00:00.000Z'; +const DAY_5 = '2026-09-03T12:00:00.000Z'; + +/** A registry with nothing in it — the commit store is the only source here. */ +function emptyRegistry() { + return { + getObject: () => undefined, + getItem: () => undefined, + listItems: () => [], + applyNavContributions: (x: any) => x, + isPackageDisabled: () => false, + getObjectOwner: () => undefined, + }; +} + +/** One `sys_metadata_commit` row, in the driver's snake_case wire shape. */ +function commitRow(id: string, createdAt: Date | string, operation = 'apply') { + return { + id, + package_id: 'pkg_crm', + organization_id: null, + operation, + message: `commit ${id}`, + actor: 'alice', + item_count: 1, + items: JSON.stringify([{ type: 'object', name: 'acct', existedBefore: true, prevVersion: 3 }]), + created_at: createdAt, + }; +} + +/** + * The five commits, in the shape ONE dialect family hands them over. + * + * `stamp` is the whole difference between the two families: `Date` is what + * Postgres and MySQL materialise for this column, the ISO-Z string is what the + * SQLite family and memory return. Rows are handed over oldest-first on purpose + * — the sort, not the driver, is what must make the timeline newest-first. + */ +function timeline(stamp: (iso: string) => Date | string) { + return [ + commitRow('cmt_d1', stamp(DAY_1)), + commitRow('cmt_d2', stamp(DAY_2)), + commitRow('cmt_d3', stamp(DAY_3)), + commitRow('cmt_d4', stamp(DAY_4)), + commitRow('cmt_d5', stamp(DAY_5), 'revert'), + ]; +} + +const asDate = (iso: string) => new Date(iso); +const asIsoText = (iso: string) => iso; + +/** An engine that answers both commit-store reads out of `rows`. */ +function engineWithCommits(rows: any[]) { + return { + registry: emptyRegistry(), + find: vi.fn(async () => rows), + findOne: vi.fn(async (object: string, query?: EngineFindOneQueryInput) => { + assertEngineFindOnePredicate(object, query); + const id = (query as any)?.where?.id; + return rows.find((r) => r.id === id) ?? null; + }), + } as any; +} + +/** + * A protocol whose `revertCommit` only RECORDS what it was handed. + * + * The defect under test is which commits `rollbackToPackageCommit` SELECTS, not + * what reverting one does; stubbing the per-commit revert keeps the assertion on + * the plan and off `revertCommit`'s own (separately pinned) machinery. + */ +function protocolWithPlanRecorder(rows: any[]) { + const protocol = new ObjectStackProtocolImplementation(engineWithCommits(rows)); + (protocol as any).revertCommit = async () => ({ + success: true, + revertedCount: 1, + failedCount: 0, + reverted: [{ type: 'object', name: 'acct', action: 'restored' }], + failed: [], + }); + return protocol; +} + +describe('[#13995] the commit timeline orders by INSTANT, not by the weekday name', () => { + it('the fixture discriminates: the old `String(...)` compare disagrees with chronology', () => { + // The positive control for everything below. If this ever passes, the + // `Date` cases stop being able to catch the defect and the pin is + // vacuous — which is exactly the state the OCC seam was in. + const stamps = [DAY_1, DAY_2, DAY_3, DAY_4].map(asDate); + const byOldStringCompare = [...stamps] + .sort((a, b) => String(b).localeCompare(String(a))) + .map((d) => d.toISOString()); + const byChronology = [...stamps] + .sort((a, b) => b.getTime() - a.getTime()) + .map((d) => d.toISOString()); + + expect(byOldStringCompare).not.toEqual(byChronology); + }); + + describe('the `Date`-materialising dialects (Postgres, MySQL)', () => { + it('listCommits returns the timeline newest-first', async () => { + const p = new ObjectStackProtocolImplementation(engineWithCommits(timeline(asDate))); + + const commits = await p.listCommits({ packageId: 'pkg_crm' }); + + expect(commits.map((c) => c.id)).toEqual([ + 'cmt_d5', 'cmt_d4', 'cmt_d3', 'cmt_d2', 'cmt_d1', + ]); + }); + + it('rollbackToPackageCommit reverts exactly the `apply` commits NEWER than the target', async () => { + const p = protocolWithPlanRecorder(timeline(asDate)); + + const result = await p.rollbackToPackageCommit({ commitId: 'cmt_d2' }); + + // Asserted as a SET: membership is decided by this site's predicate + // alone, so this case stays green if only `listCommits`' sort is + // reverted and red if only this site's is. + expect([...result.revertedCommits].sort()).toEqual(['cmt_d3', 'cmt_d4']); + // The target itself and everything older than it are untouched, and + // the `revert` commit is skipped — its effect is already captured by + // re-reverting the apply it undid. + expect(result.revertedCommits).not.toContain('cmt_d1'); + expect(result.revertedCommits).not.toContain('cmt_d2'); + expect(result.revertedCommits).not.toContain('cmt_d5'); + expect(result.success).toBe(true); + }); + }); + + describe('the ISO-text dialects (the SQLite family, memory) are unchanged', () => { + it('listCommits still returns the timeline newest-first', async () => { + const p = new ObjectStackProtocolImplementation(engineWithCommits(timeline(asIsoText))); + + const commits = await p.listCommits({ packageId: 'pkg_crm' }); + + expect(commits.map((c) => c.id)).toEqual([ + 'cmt_d5', 'cmt_d4', 'cmt_d3', 'cmt_d2', 'cmt_d1', + ]); + expect(commits[0]!.createdAt).toBe(DAY_5); + }); + + it('rollbackToPackageCommit still reverts exactly the `apply` commits newer than the target', async () => { + const p = protocolWithPlanRecorder(timeline(asIsoText)); + + const result = await p.rollbackToPackageCommit({ commitId: 'cmt_d2' }); + + expect([...result.revertedCommits].sort()).toEqual(['cmt_d3', 'cmt_d4']); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 4dd5ad2d31..5d47df00cb 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1598,6 +1598,78 @@ function versionTokensAgree(current: NormalisedVersion, expected: NormalisedVers return current.token === expected.token; } +/** The verbatim spelling the ADR-0067 timeline compared before #13995 - `String(v ?? '')`. */ +function auditInstantToken(value: unknown): string { + return value === null || value === undefined ? '' : String(value); +} + +/** + * Epoch milliseconds for a DRIVER-stamped audit column, or null when the value + * does not denote an instant. + * + * The input domain is {@link canonicalVersionInstant}'s, and it is delegated to + * rather than re-spelled: that domain (`Date` / epoch `number` / canonical ISO + * text / opaque) was measured off the drivers for #13382 and is the same set a + * `created_at` arrives in. Its canonical answer round-trips through `Date.parse` + * exactly, and it has already bounded the value to a finite, `toISOString`-able + * time, so the number below is always finite. + */ +function auditInstantMs(value: unknown): number | null { + if (value === null || value === undefined) return null; + const canonical = canonicalVersionInstant(value, auditInstantToken(value).trim()); + return canonical === null ? null : Date.parse(canonical); +} + +/** + * Compare two engine-stamped audit instants, OLDEST-FIRST (`< 0` when `a` is + * older than `b`) - the shape both a `sort` comparator and a "strictly newer + * than" filter need. + * + * ## Why this exists (#13995) + * + * `created_at` is an engine-injected audit column: it is not in `datetimeFields`, + * and `SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`, so + * the live dialects hand it out of the record read door as a JS `Date` and the + * SQLite family as canonical ISO-Z text (pinned by driver-sql's + * `sql-driver-13567-audit-stamp-materialisation.test.ts`). `String(aDate)` is + * `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"`, whose LEADING + * token is the weekday NAME - so a lexicographic compare over those strings + * orders `Fri < Mon < Sat < Sun < Thu < Tue < Wed`, which has nothing to do with + * chronology. It is stable across the whole set, so the failure is systematic + * rather than intermittent: there is never an "it worked once" to warn anyone. + * Both ADR-0067 commit-timeline consumers compared exactly that way, and one of + * them - {@link ObjectStackProtocolImplementation.rollbackToPackageCommit} - + * chooses the set of commits to UNDO with it. + * + * ## Why a sibling of {@link canonicalVersionInstant} rather than a reuse of + * {@link versionTokensAgree} + * + * The canonicalisation IS reused (see {@link auditInstantMs}). What is not is + * `versionTokensAgree`: it answers EQUALITY between two client-facing version + * TOKENS, and ordering needs `<`/`>`, not `===`. It also takes the pre-tidied + * {@link NormalisedVersion} pair an HTTP `If-Match` header produces - RFC-7232 + * quote stripping, the "no token supplied" opt-out - none of which applies to a + * column a driver stamped and no client ever sends. + * + * The opaque limb is deliberately the one `versionTokensAgree` documents: when + * either side does not denote an instant, the two are compared VERBATIM, exactly + * as this seam did before. So the only verdicts that change are pairs that denote + * instants - the defect - and nothing a host stamps into the column starts + * ordering differently. This is not a tolerant fallback over a mis-spelled key + * (#13973's standing prohibition): no alternative spelling is accepted anywhere + * here, and an unparseable value is not repaired, only left where it was. + */ +function compareAuditInstants(a: unknown, b: unknown): number { + const aMs = auditInstantMs(a); + const bMs = auditInstantMs(b); + if (aMs !== null && bMs !== null) { + return aMs < bMs ? -1 : aMs > bMs ? 1 : 0; + } + const aToken = auditInstantToken(a); + const bToken = auditInstantToken(b); + return aToken < bToken ? -1 : aToken > bToken ? 1 : 0; +} + // Lifecycle columns the engine always owns; the clone path drops them by NAME // so the insert re-stamps fresh values instead of copying the source's. Mirrors // record-validator's SKIP_FIELDS (system-injected, never author-supplied). @@ -18258,8 +18330,17 @@ export class ObjectStackProtocolImplementation implements ...(r.created_at ? { createdAt: r.created_at } : {}), })); // Newest-first; tolerate drivers that don't order by returning - // insertion order, then sort by the ISO timestamp. - mapped.sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''))); + // insertion order, then sort by the audit instant. + // + // [#13995] The comparison is on INSTANTS, not on the raw column's + // `String()` spelling. + // The comment here used to say "sort by the ISO timestamp" - an + // assumption written down and never verified, and false on the + // production default driver: Postgres and MySQL materialise this + // column as a JS `Date`, whose string spelling LEADS WITH THE WEEKDAY + // NAME, so this timeline was ordered `Fri < Mon < Sat < ...` while + // claiming newest-first. See {@link compareAuditInstants}. + mapped.sort((a, b) => compareAuditInstants(b.createdAt, a.createdAt)); return mapped; } catch (error) { // [#5980] Benign (the table has not been provisioned) falls through; @@ -18840,9 +18921,19 @@ export class ObjectStackProtocolImplementation implements // listCommits is newest-first; revert every `apply` commit strictly newer // than the target (by created_at). Revert commits are skipped (their // effect is already captured by re-reverting the apply they undid). - const targetCreatedAt = String(target.created_at ?? ''); + // + // [#13995] "Strictly newer" is an INSTANT comparison. This site both + // CONSUMES {@link listCommits}' ordering and re-derives the same + // comparison itself, so neither site could correct the other: with + // the raw column's `String()` spelling over a `Date`-materialising driver + // the predicate + // ranked by weekday NAME, and this rollback then reverted `apply` commits + // OLDER than the target while skipping the newer ones it exists to undo - + // a destructive operation planning off a wrong predicate, on every run and + // in the same way. See {@link compareAuditInstants}. + const targetCreatedAt = target.created_at; const toRevert = all.filter( - (c) => String(c.createdAt ?? '') > targetCreatedAt && c.operation === 'apply', + (c) => compareAuditInstants(c.createdAt, targetCreatedAt) > 0 && c.operation === 'apply', ); const revertedCommits: string[] = []; const failed: Array<{ commitId: string; error: string }> = []; From b0db873a78400eec33712628f6c942da71e574b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 00:26:14 +0000 Subject: [PATCH 2/3] chore(gates): re-point the isSystem census anchor and register the new engine double Both are the gates' own sanctioned repairs for the line/ledger movement the fix caused, applied with their own tooling and inspected: - `check-system-context-census --fix` RE-POINTED row 21's anchor `metadata-protocol/src/protocol.ts:1664` -> `:1736`, the 72-line shift the new `compareAuditInstants` helper block introduced above it. No row was deleted and no needle changed; the gate then reports 109 elevation read sites, 145 anchors resolving. - `check-engine-double-contract --write` ADDED one row recording that the new pin file pins 1 `findOne` double ("1 added or grown, 0 lost"). The shrink-only baseline is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --- content/docs/permissions/system-context.mdx | 2 +- scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index f86ab2cb8a..99d8a8ec00 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -112,7 +112,7 @@ that silently does not happen. | 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10712` | | 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10874` | | 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9605` | -| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1664` | +| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1736` | | 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9642`, `readonly-strict-errors.ts:66` | | 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5639` | | 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` | diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 741162ba41..8eec0452b0 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -366,6 +366,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.commit-timeline-instant-order.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.container-issue-descent.test.ts", "verb": "delete", From df231b4ece19b7f27067eb0ec488c8b596d94a8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 02:53:48 +0000 Subject: [PATCH 3/3] chore(docs): re-derive the isSystem census after merging origin/main The merge of origin/main routed content/docs/permissions/system-context.mdx through the os-regen driver, which exits 0 without text-merging and leaves git's pre-filled OURS side in place. That silently dropped the 16 anchor re-points main had landed (#13829, #13934, #13910, #13857) while keeping this branch's single re-point. This commit takes main's side of the page and re-derives every anchor from the merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on both sides once line numbers are normalised, so nothing but line numbers moved. --- content/docs/permissions/system-context.mdx | 32 ++++++++++----------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 99d8a8ec00..f6d1f58cdf 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -64,7 +64,7 @@ not on any flag. ## How the flag is set `isSystem` is **server-constructed and never client-supplied**. Inbound HTTP -cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), and neither can an action body (`packages/runtime/src/domains/actions.ts:404`). It is written by internal callers only, as an option on the engine call: @@ -103,24 +103,24 @@ that silently does not happen. | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | | 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` | | 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` | -| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1334` | +| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1421` | ### 2. Write pipeline and data integrity | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10712` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10874` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9605` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10787` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10949` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9680` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1736` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9642`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5639` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9717`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` | | 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6337` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11460` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11389` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6403` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11535` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11464` | ### 3. Sharing (`plugin-sharing`) @@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**. | 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) | | 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` | | 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` | -| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` | +| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` | | 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` | | 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` | @@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**. |:--|:---|:---|:---|:---| | 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:333` | | 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:440` | -| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:850`, `:959`, `:2916`, `:3062`, `:3229`, `:3300`, `:3489`, `:3529` | +| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:931`, `:1040`, `:2997`, `:3143`, `:3310`, `:3381`, `:3570`, `:3610` | | 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` | | 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` | | 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` | @@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | @@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| | 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:13801` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 63 | `objectql/src/engine.ts:13876` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,11 +195,11 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9588`–`9605` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9663`–`9680` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` | -| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` | ---