From ef9ab7c56fa082cad1bf8afb103b0f1a6b10b3ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 15:27:12 +0000 Subject: [PATCH 1/6] fix(metadata-protocol): listCommits emits the ISO-8601 string createdAt declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listCommits`'s declared return type says `createdAt?: string`, but the mapping assigned the raw driver value straight through. `created_at` is an engine-injected audit column, and `SqlDriver#formatOutput` repairs it only inside its `if (this.isSqlite)` arm, so Postgres and MySQL hand it out of the record read door as a JS `Date` — a value every in-process consumer received in a field the type promised was a `string`. Follows #14037's precedent: a narrow per-site `isoFromValidDate` helper converts the one measured shape (a valid `Date`) and returns every other shape, including an Invalid `Date`, unchanged — deliberately not the shared `canonicalIsoInstant` spelling, which raises RangeError on an Invalid `Date` reachable on both live dialects (#14078, on which #13973 is blocked). Fixes #14038 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...-14038-list-commits-created-at-iso.test.ts | 185 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 55 +++++- 2 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts diff --git a/packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts b/packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts new file mode 100644 index 0000000000..77b79df842 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14038] `listCommits`'s declared return type says `createdAt?: string`, + * but the mapping assigned the RAW driver value straight through: + * `...(r.created_at ? { createdAt: r.created_at } : {})`. `rows` is `any[]`, + * so tsc saw a `string` field and never checked it against what a driver + * actually hands back. + * + * ## The defect + * + * `created_at` is an engine-injected audit column: it is not in + * `datetimeFields`, and `SqlDriver#formatOutput` repairs it (both the + * builtin-audit-column repair and the `datetimeFields` fold) only inside its + * `if (this.isSqlite)` arm (`sql-driver.ts`, `formatOutput`). Postgres and + * MySQL therefore hand this column out of the record read door as a JS + * `Date`, while the SQLite family hands out canonical ISO-Z text — pinned + * live in + * `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`. + * So on the production default driver, `listCommits` handed every + * in-process consumer a `Date` in a field the type says is a `string`. + * + * ## Why the fixture drives a hand-made `Date` + * + * `@objectstack/metadata-protocol` has no driver dependency and must not + * grow one — the layering runs the other way, the same split + * `sql-driver-13567-audit-stamp-materialisation.test.ts` documents. The + * `Date` here is hand-made rather than read off a live driver, matching the + * sibling `protocol.commit-timeline-instant-order.test.ts` (#13995) and the + * #14037 family's own fixtures. + * + * ## Route: a narrow per-site conversion, NOT the shared `canonicalIsoInstant` + * + * #14037 took this exact route for its five sibling sites and deliberately + * did NOT adopt `canonicalIsoInstant` (`sys-metadata-repository.ts` / + * `database-loader.ts`), because #14078 measured an Invalid `Date` reachable + * on BOTH live dialects (a MySQL zero datetime; any Postgres year in + * 275760..294276) where that spelling's `value.toISOString()` raises + * `RangeError`, and #13973 is `pm:blocked` on that ruling. This card follows + * #14037's precedent: `isoFromValidDate` in `protocol.ts` converts the ONE + * measured shape (a valid `Date`) and returns every other shape — including + * an Invalid `Date` — UNCHANGED. §D below is the pin that this card does not + * decide #14078: it goes red the moment anyone swaps the contested spelling + * into this site. + * + * ## Reverse verification, direction predicted BEFORE running + * + * Restoring the raw assignment (`createdAt: r.created_at`) turns §A red (the + * emitted value is a `Date` instance, `typeof` is `'object'`, and + * `JSON.stringify` — not `Date` equality — is what the old REST door hid + * behind) while §B, §C and §D stay green: an already-canonical SQLite string + * is unaffected by either spelling, and neither spelling converts an Invalid + * `Date`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; + +/** Canonical instant text — exactly what `Date.prototype.toISOString` emits. */ +const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +/** + * The instant the live `Date`-materialising dialects hand back. Non-zero + * milliseconds on purpose: `String(date)` / `date.toString()` both drop + * them, so a truncating regression would stay observable rather than + * coincide with the canonical text. + */ +const PG_INSTANT = new Date('2026-03-04T05:06:07.089Z'); + +/** What SQLite hands out for the same instant — already the declared shape. */ +const SQLITE_TEXT = '2026-03-04T05:06:07.089Z'; + +/** 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(createdAt: unknown) { + return { + id: 'cmt_1', + package_id: 'pkg_crm', + organization_id: null, + operation: 'apply', + message: 'commit 1', + actor: 'alice', + item_count: 1, + items: JSON.stringify([{ type: 'object', name: 'acct', existedBefore: true, prevVersion: 3 }]), + created_at: createdAt, + }; +} + +/** An engine that answers the commit-store read out of `rows`. Read-only: `listCommits` never writes. */ +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; +} + +describe('[#14038] listCommits emits the ISO-8601 string createdAt is declared as', () => { + describe('§A the `Date`-materialising dialects (Postgres, MySQL)', () => { + it('canonicalises a JS `Date` to a canonical ISO-Z string', async () => { + const p = new ObjectStackProtocolImplementation(engineWithCommits([commitRow(PG_INSTANT)])); + + // Non-vacuity guard: a fixture that silently degraded to a string + // would keep this file green while measuring nothing. + expect(PG_INSTANT).toBeInstanceOf(Date); + + const commits = await p.listCommits({ packageId: 'pkg_crm' }); + + expect(commits).toHaveLength(1); + expect(typeof commits[0]!.createdAt).toBe('string'); + expect(commits[0]!.createdAt).toMatch(ISO_Z); + expect(commits[0]!.createdAt).toBe(PG_INSTANT.toISOString()); + }); + }); + + describe('§B the ISO-text dialects (SQLite family, memory) are unaffected', () => { + it('passes an already-canonical string through byte-identically', async () => { + const p = new ObjectStackProtocolImplementation(engineWithCommits([commitRow(SQLITE_TEXT)])); + + const commits = await p.listCommits({ packageId: 'pkg_crm' }); + + // Idempotent: the dialect that was already correct must not be reshaped. + expect(commits[0]!.createdAt).toBe(SQLITE_TEXT); + }); + }); + + describe('§C an absent column stays absent', () => { + it('omits `createdAt` rather than inventing a value', async () => { + const row = commitRow(undefined); + delete (row as any).created_at; + const p = new ObjectStackProtocolImplementation(engineWithCommits([row])); + + const commits = await p.listCommits({ packageId: 'pkg_crm' }); + + expect(commits[0]!.createdAt).toBeUndefined(); + expect('createdAt' in commits[0]!).toBe(false); + }); + }); + + describe('§D #14078 neutrality — an Invalid `Date` is NOT converted here', () => { + /** + * ⛔ This card does not decide #14078. An Invalid `Date` is measured + * reachable on both live dialects (a MySQL zero datetime; any + * Postgres year in 275760..294276), and whether the shared + * canonical-ISO spelling (`canonicalIsoInstant`) should throw on it + * (option A) or fall back to a rendering (option B) is a maintainer + * call across four packages. Until it is ruled, this site hands that + * one shape through exactly as it does today — no new throw, no + * invented rendering. This case is what makes that a PIN rather than + * a claim: it goes red the moment `canonicalIsoInstant` (or any + * spelling that reaches `.toISOString()` unconditionally) is swapped + * into `listCommits`. + */ + it('hands the value through unchanged instead of raising RangeError', async () => { + const invalid = new Date(NaN); + expect(Number.isNaN(invalid.getTime())).toBe(true); + // The contested spelling's `Date` arm, on this input, for contrast. + expect(() => invalid.toISOString()).toThrow(RangeError); + + const p = new ObjectStackProtocolImplementation(engineWithCommits([commitRow(invalid)])); + + const commits = await p.listCommits({ packageId: 'pkg_crm' }); + + // Unchanged — and specifically NOT converted, which would mean + // this card had quietly chosen a rendering for the contested shape. + expect(commits[0]!.createdAt).toBe(invalid as unknown as string); + }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c74a19e0f5..c0730b3432 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1680,6 +1680,54 @@ function compareAuditInstants(a: unknown, b: unknown): number { return aToken < bToken ? -1 : aToken > bToken ? 1 : 0; } +/** + * Canonicalise the ONE driver materialisation {@link listCommits} was + * measured to produce for `sys_metadata_commit.created_at` — a valid JS + * `Date` — into the ISO-8601 string the return type declares (`createdAt?: + * string`). Every other shape, INCLUDING an Invalid `Date`, is returned + * UNTOUCHED. + * + * [#14038] `created_at` is an engine-injected audit column: it is not in + * `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its + * `if (this.isSqlite)` arm (pinned live in driver-sql's + * `sql-driver-13567-audit-stamp-materialisation.test.ts`), so Postgres and + * MySQL hand it out of the record read door as a JS `Date` while the + * mapping below assigned it straight through as `r.created_at` — an + * unchecked value from an `any[]` row, never a measurement against the + * declared `string` return type. + * + * ⚠️ Deliberately NOT the `canonicalIsoInstant` spelling next door in + * `sys-metadata-repository.ts` / `database-loader.ts` (#14037's sibling + * sites): that spelling reaches `value.toISOString()` for ANY `Date`, which + * raises `RangeError: Invalid time value` on an Invalid `Date` — measured + * reachable on BOTH live dialects (a MySQL zero datetime; any Postgres year + * in 275760..294276) and the open subject of #14078, which #13973 is + * blocked on. Whether the shared spelling should throw there (option A) or + * fall back to a rendering (option B) is a maintainer call across four + * packages, so this repair imports NEITHER answer into a new call site: an + * Invalid `Date` is returned unchanged, exactly as the raw assignment + * passed it through today. When #14078 rules, this helper collapses into + * the shared spelling. + * + * ⛔ NOT a tolerant fallback (#13973's standing prohibition): it teaches no + * consumer to accept an off-spec shape; it converts the one measured + * producer materialisation at the producer. The + * `!Number.isNaN(value.getTime())` guard is the same one #14037 used for + * its two sibling sites, not a new spelling. + * + * ⛔ Not exported and not merged into {@link canonicalVersionInstant} above: + * that helper answers a different question (does this token denote AN + * instant at all, returning `null` when it does not) and callers of + * `listCommits` are promised the RAW value back untouched when it is not a + * valid `Date` — an absent/opaque column must still reach `sort`'s fallback + * branch and any in-process reader exactly as before. Consolidating the + * family's near-identical copies is #14078's call, not this card's. + */ +function isoFromValidDate(value: unknown): unknown { + if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString(); + return value; +} + // 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). @@ -19051,7 +19099,12 @@ export class ObjectStackProtocolImplementation implements ...(r.parent_commit_id ? { parentCommitId: r.parent_commit_id } : {}), itemCount: typeof r.item_count === 'number' ? r.item_count : 0, items: this.parseCommitItems(r.items), - ...(r.created_at ? { createdAt: r.created_at } : {}), + // [#14038] Canonicalise the ONE driver materialisation this + // column is measured to produce (a valid JS `Date`, on + // Postgres/MySQL); every other shape — including an + // Invalid `Date` — passes through unchanged. See {@link + // isoFromValidDate}. + ...(r.created_at ? { createdAt: isoFromValidDate(r.created_at) as string } : {}), })); // Newest-first; tolerate drivers that don't order by returning // insertion order, then sort by the audit instant. From 71bff0f788df2d0fb35a06e5a01751274d3e3206 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 15:39:23 +0000 Subject: [PATCH 2/6] chore: re-anchor system-context census after protocol.ts line shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:system-context-census pins doc-table anchors by line number; the listCommits fix above inserted ~40 lines earlier in protocol.ts and shifted one anchored elevation-read site. Mechanical re-anchor via `node scripts/check-system-context-census.mjs --fix` — no behaviour change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index f7448ca67c..8a777789f3 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:11289` | | 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11472` | | 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10024` | -| 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:1746` | +| 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:1794` | | 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:10072`, `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:5891` | | 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:3735`, `:3745`, `:3772` | From 45826562c6773e3e0d969d91e9235e1e353ed67a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 15:49:00 +0000 Subject: [PATCH 3/6] chore: pin the new listCommits fake-engine double check:engine-double-contract requires every findOne/update/delete fake engine double in a test file to be registered in the pinned ledger. Registers the read-only findOne double the new #14038 pin test uses. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 9772bf3184..7230e4df50 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -111,6 +111,11 @@ "verb": "update", "pinned": 3 }, + { + "file": "packages/metadata-protocol/src/protocol-14038-list-commits-created-at-iso.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol-publish-drafts-advisories.test.ts", "verb": "delete", From 7011557711a1e796f7b86ccd6e590edb43f4ee89 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 16:04:06 +0000 Subject: [PATCH 4/6] chore: add changeset for the listCommits createdAt fix Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...ta-protocol-list-commits-created-at-iso.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/metadata-protocol-list-commits-created-at-iso.md diff --git a/.changeset/metadata-protocol-list-commits-created-at-iso.md b/.changeset/metadata-protocol-list-commits-created-at-iso.md new file mode 100644 index 0000000000..9a95040020 --- /dev/null +++ b/.changeset/metadata-protocol-list-commits-created-at-iso.md @@ -0,0 +1,21 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +`listCommits` now emits the ISO-8601 string its declared return type +(`createdAt?: string`) promises, instead of asserting the raw driver value + +`created_at` on `sys_metadata_commit` is an engine-injected audit column; +`SqlDriver#formatOutput` repairs it only inside its `if (this.isSqlite)` +arm, so Postgres and MySQL hand it out of the record read door as a JS +`Date` — a value every in-process consumer of `listCommits` received in a +field the type said was a `string`. The REST door (`GET +/packages/:id/commits`) was unaffected: `JSON.stringify` already renders a +`Date` as canonical ISO-Z text, so only in-process callers saw the mismatch. + +The repair is a narrow per-site conversion at the producer: an already- +canonical SQLite string and an absent column both pass through unchanged, +and — deliberately — so does an Invalid `Date`, rather than adopting the +shared `canonicalIsoInstant` spelling, which raises `RangeError` on that one +shape (measured reachable on both live dialects; the open subject of a +separate, unresolved card this change does not decide). From 03e2ccc742e359fae22074f5e87a96ebe91e8def Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 16:55:23 +0000 Subject: [PATCH 5/6] chore: re-anchor system-context census after merging origin/main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging origin/main shifted lines in identity-write-guard.ts, auth-plugin.ts and share-link-service.ts (unrelated incoming commits); mechanical re-anchor via `node scripts/check-system-context-census.mjs --fix` — no behaviour change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/permissions/system-context.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 8a777789f3..6a47cc7d28 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -97,7 +97,7 @@ that silently does not happen. | 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | -| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1405` | +| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1412` | | 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` | | 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` | | 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` | @@ -117,7 +117,7 @@ that silently does not happen. | 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:5891` | | 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:3735`, `:3745`, `:3772` | | 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` | +| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` | | 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:6589` | | 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:12084` | | 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:12013` | @@ -135,7 +135,7 @@ The largest single consumer — **17 of the 106 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:1476` (guard at `:1501`) | | 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` | | 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` | -| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` | +| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` | | 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:165`, `:390` | From c313a7c20519743c80771961a0b9dd381670fb8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 01:37:04 +0000 Subject: [PATCH 6/6] chore: re-anchor system-context census after merging origin/main Line numbers in protocol.ts and engine.ts shifted after merging main's in-flight work (through f594e70d7); re-run scripts/check-system-context-census.mjs --fix to repair pure line rot. 22 anchors rewritten, 0 elevation read sites added or removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/permissions/system-context.mdx | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 6a47cc7d28..be7b12f001 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ that silently does not happen. | # | 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:11289` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11472` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10024` | -| 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:1794` | -| 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:10072`, `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:5891` | -| 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:3735`, `:3745`, `:3772` | +| 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:11290` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` | +| 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:1795` | +| 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:10073`, `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:5892` | +| 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:3736`, `:3746`, `:3773` | | 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:99` | -| 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:6589` | -| 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:12084` | -| 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:12013` | +| 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:6590` | +| 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:12085` | +| 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:12014` | ### 3. Sharing (`plugin-sharing`) @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 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:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4715`, `:6078`, `:6326`, `:6757`, `:6950` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4789`, `:6203`, `:6451`, `:6882`, `:7075` | | 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:326`, `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` | @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3542` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14433` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 62 | `objectql/src/engine.ts:3543` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 63 | `objectql/src/engine.ts:14463` | 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,8 +195,8 @@ 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:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:702` | | "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:10007`–`10024` | -| "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:1537` (#3493 / #6640) | +| "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:10008`–`10025` | +| "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:1540` (#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:299` | | "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1520`, `:1549`; `domains/actions.ts:404` |