Skip to content

fix(driver-sql): write an undeclared builtin audit timestamp in its column datetime form (MySQL: sys_jwks never stored, /auth/jwks 500) - #21272

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-21259-mysql-jwks-updated-at
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-21259-mysql-jwks-updated-at

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21259

Clause-②: no

What was wrong

initObjects creates created_at and updated_at on every managed table (createAuditTimestampColumn, DATETIME(3) on MySQL). The engine's sys_stamp_audit_insert hook stamps both on every insert as new Date().toISOString() text. formatInput rewrote that text into the MySQL literal only for a column the object DECLARED as Field.datetime. The registry declares both columns on most objects, but injects nothing for managedBy: 'better-auth' or systemFields: false. On those objects an undeclared audit column reached MySQL as 2026-10-01T23:35:47.598Z, and MySQL refused it (ER_TRUNCATED_WRONG_VALUE).

Producer, measured

  • Bound values. The MySQL general log at 8dea55d3 shows every sys_jwks INSERT bound created_at as 2026-10-01 23:35:47.598 and updated_at as 2026-10-01T23:35:47.598Z: one instant in two spellings. Only the non-key columns were extracted; key material was not read.
  • Who writes updated_at. I ran a scratch vitest (never committed): ObjectQLPlugin, a capturing driver, and the real SysJwks from platform-objects.
    • The caller payload (the better-auth jwt plugin shape) has the keys id, public_key, private_key, alg, crv, created_at.
    • driver.create received the same keys plus updated_at, a string in toISOString() form, equal to the created_at the hook re-stamped.
    • The registered object's fields are id, public_key, private_key, alg, crv, created_at, expires_at.
  • Where that leaves the producer. better-auth's jwks schema has no updatedAt (better-auth/dist/plugins/jwt/schema.mjs). The producer is the engine's audit hook (packages/objectql/src/plugin.ts, applyToRecord), not the auth adapter. The value is a correct instant in the platform's canonical form. What was missing is the driver formatting the column it provisioned. So the fix is in driver-sql, and plugin-auth is untouched.

The class, counted

The objects that boot registers: GET /api/v1/meta/object on the CRM boot at 8dea55d3, MySQL, 81 objects.

declares objects
created_at and updated_at 70
created_at only 9: sys_member, sys_invitation, sys_team_member, sys_oauth_access_token, sys_oauth_refresh_token, sys_oauth_client_resource, sys_jwks, sys_scim_connection_binding, sys_scim_group_member
updated_at only 0
neither 2: sys_oauth_client_assertion, sys_scim_identity_tombstone

Every declared audit column is datetime. In the baseline boot, two of the eleven objects failed at a door: sys_jwks 5 times and sys_member 2 times. The seeded admin had no organization membership on MySQL. Declaring updated_at on sys_jwks alone would have closed 1 of the 11.

The fix

The fix is in packages/drivers/driver-sql/src/sql-driver.ts:

  • registerManagedObjectMetadata records the builtin audit columns a managed object does not declare (undeclaredAuditTimestampFields). This runs at every registration, so a later declaration clears the entry. External objects are excluded: their remote columns are not this driver's.
  • formatInput writes those columns through storageDatetimeValue, the same rule a declared Field.datetime takes. Every write door goes through it: create, bulkCreate, upsert, update and updateMany.
  • On SQLite and PostgreSQL the hook's text is already canonical and is bound unchanged, byte for byte.
  • One input shape changes on SQLite: a JS Date written to an undeclared audit column is now stored as canonical ISO text, the same as for a declared Field.datetime. Before, it was stored as an epoch INTEGER and read back as a number. A column the object declares keeps its declared type.
  • nowColumnDefault and migrateMysqlDatetimeColumns are not touched.

Pins

packages/drivers/driver-sql/src/sql-driver-21259-undeclared-audit-timestamp-write.test.ts declares one cell per dialect through declareDialectCell: SQLite always, live PostgreSQL and MySQL where provisioned. CI's Temporal Conformance (live PG + MySQL) runs it. Each cell:

  • covers the two shapes the census found (created_at only, and neither);
  • writes the hook's exact value through create, bulkCreate, upsert, update and updateMany (the last two under preserveAudit) and reads back the same instant;
  • asserts the value the driver BOUND: the MySQL literal on MySQL, and the hook's text unchanged on SQLite and PostgreSQL (the control);
  • includes a Date case.

The door pin follows the precedent of the seat's answer on #21227 (5940180378, option B): a driver pin of record plus the one-off door measurement below. No live-dialect harness boots plugin-auth.

Door measurement

All runs used pnpm dev:crm -- --fresh and the seeded admin's session. The MySQL server was 8.0.46 with zone +08:00; the PostgreSQL server was 16.14 with zone Asia/Shanghai.

door MySQL, before (8dea55d3) MySQL, after (d18427f4, driver-sql rebuilt) PostgreSQL, after SQLite, after
GET /api/v1/auth/jwks 500 200: one public key (OKP, Ed25519, EdDSA), no private member 200 200
GET /api/v1/auth/token 500 200: a three-segment JWT, header alg EdDSA with a kid 200 200
GET /api/v1/auth/get-session 200, no set-auth-jwt header 200, set-auth-jwt present 200, present 200, present
sys_jwks / sys_member rows 0 / 0 1 / 1 (created_at = updated_at, UTC wall clock) 1 / 1 not read
Insert operation failed WARN lines naming Incorrect datetime value 7 (5 sys_jwks, 2 sys_member) 0 0 failed inserts 0 failed inserts

The PostgreSQL and SQLite doors were not measured before the fix (NOT MEASURED: door-before on pg/sqlite). Their control of record is the driver pin's PostgreSQL and SQLite cells, which were green before the fix as well as after it.

Reverse verification

  • Before the fix. The pin ran against the unfixed driver with 21 cases, before the updateMany case was added. 6 failed: 5 on live MySQL, each Incorrect datetime value: '2026-10-01T21:49:27.479Z', and the SQLite Date case, which received 1790891367479. Live PostgreSQL had 0 failures.
  • Ablation at e7586a892e. node scripts/ablation-replace.mjs replaced the arm's registry read with undefined. The anchor hit went 1 to 0 and the blob changed d08cb7e95a4a to 5e6e99e8b352. Result: 7 failed and 17 passed. The failures were 6 on live MySQL (create ×2, bulkCreate, upsert, update, updateMany) and 1 on SQLite (the Date case). Live PostgreSQL had 0, as predicted.
  • Restore. The restore was proven by blob identity with HEAD, an empty git diff HEAD and a clean git status. The pin imports the driver from source (./index.js), so no dist/ leg applies.

Local verification (HEAD e7586a892e)

  • The pin. 24/24 at e7586a892e: 8 cases × 3 cells, live PostgreSQL 16.14 and MySQL 8.0.46, TZ=America/New_York, OS_EXPECT_LIVE_DIALECT_MATRIX=1.
  • @objectstack/driver-sql, whole suite, same environment. Run at d18427f4. Since then e7586a892e adds only a test case and the changeset, and sql-driver.ts is unchanged. Result: 224 files, 5390 passed, 1 skipped. The reporter printed "all 3 dialects were exercised".
  • Inheritors. @objectstack/driver-sqlite-wasm: 36 files, 675 passed. @objectstack/driver-turso: 87 files, 2349 passed, 33 skipped.
  • Typecheck. typecheck exit 0 for driver-sql, driver-sqlite-wasm and driver-turso. driver-sql's program includes the new test file (tsc --listFiles).
  • Gates. pnpm check:driver-conformance exited 0 before the first edit and after the last commit. The gate list is node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, run at e7586a892e; per-gate results are in the dev report on On MySQL, the JWT signing-key row is never written (Incorrect datetime value for sys_jwks.updated_at), so /auth/jwks and /auth/token answer 500 and no OIDC/MCP token can be issued #21259.

Acceptance notes

  • Filters. A filter comparand on an undeclared audit column was measured on live MySQL, with $gt and equality against …Z text. Both answered the right rows, so the read side needs no change.
  • An existing fixture. sql-driver-11176-bulk-and-merge-updated-at.test.ts §2 routes around this class in its fixture: it sends toISOString() on SQLite and a Date elsewhere. Left untouched.
  • Clause-②. No authorable or API accept set moves, and the audit columns are readonly to clients. The write door stops producing a literal MySQL refuses, for a value the declared-datetime path already took on every dialect.
  • Out of scope. The sys_activity table is missing on MySQL in this boot; driver-sql on MySQL: a declared datetime field defaulting to NOW() gets a precision-less CURRENT_TIMESTAMP default on its datetime(3) column, so MySQL 8.0 refuses the table: sys_activity is never created and its data door answers 500 #21241 is not addressed here.
  • A log finding, reported for the seat to file. On MySQL, the [Better Auth] ERROR log line printed the failing INSERT with its bound values, while the engine's WARN line for the same failure printed [statement and bound values redacted]. Details are in the dev report.

Generated by Claude Code

claude added 2 commits October 1, 2026 23:42
…olumn's datetime form

The engine's audit hook stamps created_at/updated_at as toISOString() text on
every insert, and initObjects provisions both columns on every managed table,
but formatInput only rewrote the value into the column's storage form for a
DECLARED Field.datetime. An object the registry injects no audit fields into
(managedBy better-auth, systemFields false) bound the raw ISO text, which MySQL
refuses: sys_jwks never stored a signing key and sys_member never stored a
membership.

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 4 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via SqlDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f148852752a2544bb25674cbc3385e279896e6c2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cc9a4e42a429f56062a7e53673112b431eef72f2 — the merge of head e7586a892e7b02bef5d0635f616d15070c45e8a7 into base f148852752a2544bb25674cbc3385e279896e6c2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cc9a4e42a429f56062a7e53673112b431eef72f2 && git checkout cc9a4e42a429f56062a7e53673112b431eef72f2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f148852752a2544bb25674cbc3385e279896e6c2 e7586a892e7b02bef5d0635f616d15070c45e8a7 && git checkout -B drift-repro f148852752a2544bb25674cbc3385e279896e6c2 && git merge --no-ff e7586a892e7b02bef5d0635f616d15070c45e8a7

node scripts/docs-audit/affected-docs.mjs --json f148852752a2544bb25674cbc3385e279896e6c2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f148852752a2544bb25674cbc3385e279896e6c2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e7586a892e7b02bef5d0635f616d15070c45e8a7
Local-runs: none

Inputs: card #21259 (body, claim 5942590152, os-dev-report 5943202786), PR #21272 (body, 3-file list, three-dot diff against merge base 8dea55d3, which is byte-identical to GitHub's diff: +262/-0), the head's files read with git show, and the head's check-runs read last. Nothing was built, run or re-run.

① Derived judgments

  1. Registry undeclaredAuditTimestampFields — right. Installed in registerManagedObjectMetadata, the one seam both managed doors pass through: initObjects calls registerObjectMetadata as its first step, and a skipSchemaSync boot calls registerObjectMetadata and stops. It is keyed by the same tableName the sibling coercion registries (datetimeFields, dateFields, jsonFields) use, so formatInput(object, …) resolves it exactly as it resolves the declared-datetime set. The entry is a filter over AUDIT_TIMESTAMP_COLUMNS (two names) by non-declaration: nothing else can enter it.

  2. The formatInput arm — right on every write door. All six formatInput call sites in the driver are managed-object write doors: create, update, upsert, bulkCreate, updateMany, and rotatedUpdateById (the shard branch of update, which the PR body does not name but which inherits the fix because it formats before it stamps). The arm runs after the declared-datetime arm and before the date/time arms, skips null/undefined, and calls storageDatetimeValue, the identical function a declared Field.datetime takes: canonicalUtcDatetime (core's temporalStorageForm) renders a parseable value as canonical text and hands an unparseable one back untouched, and mysqlDatetimeLiteral rewrites only an exactly-canonical string. So nothing new can be silently reinterpreted; junk still fails loudly at the server. The remaining .insert/.update sites in the file are the driver's own tables (the sequences table, the lock key, the widening rewrite), not object rows. The driver's own stamps are untouched and were never the producer: stampInsertTimestamps (SQLite only, ISO text) and updatedAtStamp (ISO text on SQLite, now(3)/now() raw elsewhere) are applied after formatInput, so the caller's text reaches MySQL only under preserveAudit, which is exactly the path the update/updateMany pins exercise.

  3. Bounded to managed objects' audit columns — right. A declared column of any type is excluded ("the declaration wins"), consistent with the read side's audit arm in readPresentationKind. External objects are excluded in fact, not by comment: registerExternalObject writes none of this map, so the lookup is undefined and the arm is skipped, and initObjects is assertSchemaMutable-gated so a federated datasource cannot reach the managed registration at all. Residual, not a defect: a managed object registered DDL-free over a hand-migrated table lacking the column gets an entry, but the arm only formats a value the caller supplied, and a write naming a missing column fails exactly as before.

  4. Recompute per registration — right. The entry is assigned, not merged, so a re-registration that now declares the column drops it. The inverse edge (a declaration later removed) leaves the pre-existing additive datetimeFields set and the new entry both naming the column; both arms apply one idempotent function (a naive MySQL spelling is read as UTC and re-rendered byte-identically), so the double pass is a no-op.

  5. The producer, and the fix site — right. At the head, packages/objectql/src/plugin.ts applyToRecord sets record.updated_at to new Date().toISOString() on every insert and update unconditionally, its own docblock stating the premise: driver-sql creates both columns on every table. The better-auth jwks schema carries no updatedAt, so the adapter is not the producer; the dev's measurement matches the code. The VALUE is the platform's canonical instant; the REFUSED LITERAL is produced at the driver's bind, the layer that chose DATETIME(3) for the column it provisioned. Fixing the driver's write formatting is therefore the producer-side fix the card asked for and not a per-caller workaround: declaring updated_at on sys_jwks would close 1 of the 11 objects the census found (9 created_at-only, 2 neither), while this closes all 11 and every future managedBy: 'better-auth' / systemFields: false object. The opt-out rows are confirmed in packages/objectql/src/registry.ts (applySystemFields: "the two opt-out rows (systemFields: false, managedBy: 'better-auth')").

  6. The SQLite storage-form delta — Clause-②: no is right. The accept set does not move: a JS Date written to an undeclared audit column was accepted before and still is. What moves is the stored form of that one input on one dialect, from an epoch INTEGER that read back as a number (presentAuditTimestampOutput passes non-strings through, ADR-0074's "total" rule) to the canonical text, which is the shape ADR-0053 D-F1 already rules for the audit stamps at every read door on every dialect. No reader depended on the integer form: a grep over packages/** and examples/** for a numeric reading of created_at/updated_at finds none, and every in-tree producer of an undeclared audit value writes text (the engine hook, the driver's stamps, REST's JSON); the one direct-driver fixture that writes a Date (sql-driver-11176-bulk-and-merge-updated-at.test.ts §2) deliberately sends text on SQLite. Not a (narrowing) either. Residual, outside this card: a row ALREADY holding an INTEGER in an undeclared audit column on SQLite still reads back as a number, since the audit read repair does not fold numbers; its measured producer set is empty, so I note it rather than escalate it.

  7. The pins and the ablation — they prove what they claim, with PostgreSQL as the control. One cell per dialect through declareDialectCell, so an unprovisioned live cell is declared and is fatal under the matrix expectation flag. CI's required Temporal Conformance (live PG + MySQL) job runs the WHOLE driver-sql suite under TZ=America/New_York with both server URLs and that flag, so the new file runs live in a required job; that job is success on this head. The assertions read the driver's actual bind through knex query events rather than inferring it from a read-back: the MySQL literal on MySQL plus not.toContain of the ISO text, the hook's text byte-identical on SQLite and PostgreSQL, read-back equality through findOne on all five doors, the Date case, and a shape guard proving the fixture really is undeclared (declaredFieldsFor, datetimeFields, columnInfo). The reported before-fix run (6 failed: 5 live MySQL plus the SQLite Date case, PostgreSQL 0) and the ablation at the committed head (registry read replaced by undefined: 7 failed, the same six MySQL doors now including updateMany plus the SQLite Date case, PostgreSQL 0, restore proven by blob identity) are the dev's readings, not re-run here, and their direction is what the diff predicts. PostgreSQL is a genuine control: it parses the canonical text natively and binds a Date as a timestamp, so it is green with and without the arm, which is what makes MySQL's six and SQLite's one attributable to the arm and nothing else.

  8. Untouched, confirmed. The 42 lines added to sql-driver.ts are a documented field declaration, five lines in registerManagedObjectMetadata and twenty-two in formatInput; nowColumnDefault and migrateMysqlDatetimeColumns are not in the diff. sys-jwks.object.ts is not in the file list, so its key-material fields are untouched, and no packages/plugins/plugin-auth/** path is touched. SqliteWasmDriver and TursoDriver extend SqlDriver and override none of formatInput, registerManagedObjectMetadata or storageDatetimeValue (Turso's remote path calls this.formatInput), so the arm reaches both inheritors unchanged, matching the dev's suite runs.

② Semver level

.changeset/21259-undeclared-audit-timestamp-mysql.md: @objectstack/driver-sql patch — right. The package is published (version 17.5.0, no private flag) and this is a bug fix in it. Clause-②: no stands at line start in the changeset (line 7) and in the PR body (line 3); no direction arm is owed. The prose is accurate against the code: the driver creates both columns on every table it builds (initObjects and ensureShardTable both call createAuditTimestampColumn twice), the engine stamps both as ISO text, only a declared Field.datetime was rewritten, the two opt-out rows are the registry's, and the five named doors all format the column (the rotation branch of update too). The SQLite paragraph states the storage-form delta and its direction correctly. On public surface: the one added member is protected on an exported class, additive, not an accept set and not a documented API, so the no holds.

③ Boundary flags

Record written at 2026-10-02T00:34Z.

Implemented-by: claude/issue-21259-mysql-jwks-updated-at
Reviewed-by: session_017xfMoEjKUuSh2xYB8sCozp

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 00:40
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 00:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 95b91cc Oct 2, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21259-mysql-jwks-updated-at branch October 2, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant