Merged
21 changes: 21 additions & 0 deletions .changeset/metadata-protocol-list-commits-created-at-iso.md
Original file line numberDiff line numberDiff line change
@@ -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).
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: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:1747` |
| 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` |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
});
55 changes: 54 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1681,6 +1681,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).
Expand DownExpand Up@@ -19069,7 +19117,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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/metadata-protocol-list-commits-created-at-iso.md
Original file line numberDiff line numberDiff line change
@@ -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).
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: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:1747` |
| 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` |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
});
55 changes: 54 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1681,6 +1681,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).
Expand DownExpand Up@@ -19069,7 +19117,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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/metadata-protocol-list-commits-created-at-iso.md
Original file line numberDiff line numberDiff line change
@@ -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).
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: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:1747` |
| 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` |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
});
55 changes: 54 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1681,6 +1681,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).
Expand DownExpand Up@@ -19069,7 +19117,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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/metadata-protocol-list-commits-created-at-iso.md
Original file line numberDiff line numberDiff line change
@@ -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).
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: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:1747` |
| 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` |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
});
55 changes: 54 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1681,6 +1681,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).
Expand DownExpand Up@@ -19069,7 +19117,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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/metadata-protocol-list-commits-created-at-iso.md
Original file line numberDiff line numberDiff line change
@@ -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).
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: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:1747` |
| 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` |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
});
55 changes: 54 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1681,6 +1681,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).
Expand DownExpand Up@@ -19069,7 +19117,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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/metadata-protocol-list-commits-created-at-iso.md
Original file line numberDiff line numberDiff line change
@@ -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).
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: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:1747` |
| 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` |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
});
55 changes: 54 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1681,6 +1681,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).
Expand DownExpand Up@@ -19069,7 +19117,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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/metadata-protocol-list-commits-created-at-iso.md
Original file line numberDiff line numberDiff line change
@@ -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).
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: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:1747` |
| 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` |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
});
55 changes: 54 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1681,6 +1681,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).
Expand DownExpand Up@@ -19069,7 +19117,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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/metadata-protocol-list-commits-created-at-iso.md
Original file line numberDiff line numberDiff line change
@@ -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).
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: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:1747` |
| 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` |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
});
55 changes: 54 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1681,6 +1681,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).
Expand DownExpand Up@@ -19069,7 +19117,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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading