Skip to content

Allow backdating createdAt/updatedAt on unscoped Stack.create() - #214

Open
cuibonobo wants to merge 3 commits into
mainfrom
claude/issue-203-plan-cfcqjq
Open

Allow backdating createdAt/updatedAt on unscoped Stack.create()#214
cuibonobo wants to merge 3 commits into
mainfrom
claude/issue-203-plan-cfcqjq

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Aug 31, 2026

Copy link
Copy Markdown
Member

Summary

Closes #203.

Stack.create() stamped createdAt/updatedAt from new Date() unconditionally, so importing an existing archive (dated posts, migrated content) collapsed every record to the import moment. Since a full-trust caller already picks a record's creation position via a client-minted id, letting createdAt agree with it closes an inconsistency rather than opening a new capability — the framing the issue itself proposes.

  • BackdatableCreateRecordOptions adds createdAt?: Date / updatedAt?: Date.
  • Unscoped Stack.create() accepts both unconditionally (full trust — an embedded app, or a server's own code).
  • ScopedStack.create() accepts both only from the stack owner acting alone (undelegated, authenticated as themselves) — the same tier that already gates hard delete, commitMigration(), and includeUnlisted. A grantee, or a delegated app acting for the owner (either direction), is refused with StackPermissionError.
  • Over the wire, a server built on ScopedStack inherits this automatically: an owner-authenticated POST /records may carry both fields, anyone else's has them ignored, exactly as entityId/principalId already are. No client-side (adapter-api) code changes were needed — it already serializes the full record, Dates included, via JSON.stringify; the old spec text was purely a server-side instruction to discard the fields, which is what's changing.
  • Omit id and it's derived from createdAt's timestamp, so the two agree by construction. Supply both and they're checked against each other using the same idTimestampSkewMs tolerance the ordinary id-vs-current-time check already uses — disagreement throws StackValidationError rather than silently diverging. An owner's plain id-only create through ScopedStack is unaffected: still checked against the current time, not against createdAt.
  • updatedAt defaults to createdAt (not the actual current time), so a plain import doesn't fabricate a fake edit or pollute version history.
  • Hardened: both fields are validated as real, representable Dates (an Invalid Date's NaN timestamp would otherwise silently switch off the ordering/skew checks instead of failing them; a createdAt outside 1970-01-013084-12-12 has no ID that could agree with it), and Dates are copied on the way in so a reused mutable Date across an import loop can't retro-edit already-written records.

Bug caught while implementing the first pass: deriving an id straight from createdAt via generateId(timestamp) hits its monotonic "never sort before a live id already minted this process" floor — a backdated import would get silently clamped forward to "now" the moment any live create() had already run in that process. Added generateIdForTimestamp() in id.ts, which mints from an explicit timestamp without consulting or advancing that floor.

Spec

  • docs/spec/data-model.md § Record IDs — new "Backdating on import" subsection.
  • docs/spec/wire-format.md § Records — the createdAt/updatedAt server-assignment rule now carries the owner-authenticated exception, mirroring how appId is already called out as a deliberate exception.

Verification

pnpm run format:check && pnpm run lint && pnpm test && pnpm run build && pnpm run typecheck

All green across every workspace package (962 tests in @haverstack/core).

Notes for reviewers

  • This PR went through two rounds after initial review: a hardening pass (Invalid Date / pre-epoch / post-3084 range checks, defensive Date-copying) and then a scope expansion once it became clear the original "unscoped Stack only, never on ScopedStack" design left server-hosted deployments with no path at all — even for the stack's own owner. The ownerActingAlone carve-out on ScopedStack.create() closes that gap using an existing, precedented trust tier rather than inventing a new one.
  • Considered silently ignoring createdAt/updatedAt for non-owner ScopedStack.create() callers (matching the wire behavior for entityId/principalId) instead of throwing. Went with throwing, consistent with this codebase's existing philosophy for scoped writes ("refused rather than silently ignored, so an app never believes it published something it didn't" — see mayGrantAccess()'s doc comment).
  • Renamed UnscopedCreateRecordOptionsBackdatableCreateRecordOptions since ScopedStack.create() now accepts it too (conditionally).

Closes #203.

Stack.create() stamped createdAt/updatedAt from new Date() unconditionally,
so importing an existing archive collapsed every record to the import
moment. Since a full-trust caller already picks a record's creation
*position* via a client-minted id, letting createdAt agree with it closes
an inconsistency rather than opening a new hole.

- UnscopedCreateRecordOptions adds createdAt/updatedAt to Stack.create()
  only. ScopedStack.create() never accepts them, even past the type system
  (a grantee could otherwise forge a sort position the same way a raw id
  could), and the wire format is unchanged.
- Omit id and it's derived from createdAt's timestamp, so the two agree by
  construction; supply both and they're checked against each other with
  the same idTimestampSkewMs tolerance ScopedStack's grantee check uses.
- updatedAt defaults to createdAt, not the actual current time, so a plain
  import doesn't fabricate a fake edit.
- Added generateIdForTimestamp() to id.ts: deriving an id from an explicit
  createdAt must not go through generateId()'s monotonic "never sort before
  a live id already minted this process" floor, or a backdated import would
  silently get clamped forward to "now" the moment any live create() has
  run first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKowhhMr6TnFEKsgaYtNYm
@changeset-bot

changeset-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f5ef1e9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
@haverstack/core Minor
@haverstack/adapter-api Patch
@haverstack/adapter-local Patch
@haverstack/blob-adapter-disk Patch
@haverstack/commons Patch
@haverstack/record-adapter-sqlite Patch
@haverstack/sqlite-shared Patch
@haverstack/wire-types Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Hardening follow-up to the createdAt/updatedAt backdating options. The
privilege boundary those options draw is sound — ScopedStack.create()
refuses both fields before doing anything else, and
generateIdForTimestamp() neither reads nor advances generateId()'s
monotonic floor, so an import can't perturb live ID generation. What was
missing was input validation on the full-trust path, where three inputs a
real import can produce were accepted and stored:

- An Invalid Date (what a malformed source row parses to) has a NaN
  getTime(), and every comparison against NaN is false — so it didn't slip
  past the updatedAt/createdAt ordering check and the id/createdAt skew
  check, it switched them off. The record then persisted with an
  epoch-zero ID and a createdAt whose toISOString() throws RangeError in
  serializeRecord(), leaving that record and any wire response containing
  it permanently unreadable.
- A pre-epoch createdAt surfaced as a bare RangeError ("Not defined for
  negative numbers!") thrown from inside the ID encoder, naming neither
  the field nor the record — and was accepted outright when an explicit
  `id` skipped ID derivation.
- A createdAt past 3084-12-12 overflowed the 9-character timestamp prefix,
  minting a 13-character ID that isValidIdFormat() rejects — the library
  producing an ID it refuses on the way back in. Year-9999 sentinels are
  ordinary in imported data.

Both fields are now checked for validity and for the range a record ID's
timestamp prefix can encode, as a StackValidationError naming the field,
whether or not an `id` is supplied. generateIdForTimestamp() carries the
same bound itself, since unlike generateId() it encodes a caller-supplied
timestamp rather than Date.now().

Also:
- The ordering check now compares against the effective createdAt, so an
  updatedAt supplied on its own is caught too. Previously it required both
  fields, letting { updatedAt: <past> } store a record modified before it
  was created.
- Caller Dates are copied rather than stored by reference. An import loop
  that advances and reuses one Date across rows would otherwise retro-edit
  every record it had already written, with no version bump and no change
  event.

Spec and changeset document the new rules, plus a note that backdated
records fall behind an updatedAt sync cursor by construction.

Copy link
Copy Markdown
Member Author

Pushed e2799d4 — a security review of this PR, with input-validation hardening applied.

The privilege boundary holds. No escalation path was found. Three things are right and worth naming so they don't get "simplified" later:

  • The ScopedStack.create() guard is the first statement in the method, before the grant check, and throws rather than silently dropping.
  • It uses 'createdAt' in opts, which is strictly broader than the {...opts} spread that forwards to Stack.create()in walks the prototype chain, spread copies own-enumerable only. There is no object shape that evades the check but still gets forwarded.
  • generateIdForTimestamp() neither reads nor advances generateId()'s monotonic floor. This is the one that could have gone badly: had it advanced the floor, a single unscoped import of a far-future date would have dragged every subsequent live ID in the process forward with it, including concurrent scoped writers'.

What the follow-up commit fixes. All five are reachable only by a full-trust caller, so none is an escalation — but "full trust" here means an import script reading a third-party archive, so the data is untrusted even when the caller isn't.

  1. Invalid Date disabled both new checks. A malformed source row parses to NaN, and every comparison against NaN is false — so an Invalid Date didn't slip past the ordering and skew checks, it switched them off. Confirmed: { id: <year-2000 id>, createdAt: new Date('nope') } was accepted despite a 26-year disagreement, stored with the epoch-zero ID 000000000397, and serializeRecord() then throws RangeError: Invalid time value on .toISOString() — making that record and any wire response containing it permanently unreadable. Adapters also diverged: SQLite's toMs() yields NaN → NULL → reads back as 1970.
  2. Pre-epoch createdAt surfaced as a bare RangeError ("Not defined for negative numbers!") from inside the ID encoder, naming neither field nor record — and was accepted outright when an explicit id skipped ID derivation.
  3. Far-future createdAt minted a malformed ID. Past 3084-12-12T12:41:28.831Z the 9-char prefix overflows: new Date('9999-01-01')76e1x6xc00e15, 13 chars, isValidIdFormat() → false. The library minting an ID it rejects on the way back in. generateId() can't reach this (it encodes Date.now()); generateIdForTimestamp() encodes arbitrary caller input, so it needs the bound.
  4. updatedAt alone skipped the ordering check — the guard required both fields, so { updatedAt: <past> } stored a record modified before it was created.
  5. Caller Dates were stored by reference — mutating one after create() returned retroactively changed the stored record, with no version bump and no change event. An import loop that advances and reuses a single Date would end with every record sharing the last date.

Both clock fields are now validated for validity and encodable range as a StackValidationError naming the field, whether or not an id is supplied; generateIdForTimestamp() carries the same bound; the ordering check compares against the effective createdAt; Dates are copied on the way in.

Two judgement calls left open for you:

  • Pre-epoch dates are now rejected rather than supported. A record ID's timestamp prefix can't encode them, so createdAt and id could never agree — the invariant this PR is built on. If importing genuinely pre-1970 content is a goal, that's a spec decision, not a validator one; the docs now say such dates belong in content fields.
  • Backdated records are invisible to an updatedAt sync cursor by construction — they land behind it. Inherent to the feature, not a defect, but it was undocumented; noted in the spec now.

13 regression tests added. Full suite green: @haverstack/core 942 → 955, all other packages unchanged, plus lint, format, build, typecheck. Note the Verification section above still cites 942.


Generated by Claude Code

…wire

Follow-up to the createdAt/updatedAt backdating options. As scoped, that
change only worked for a caller with direct in-process access to an
unscoped Stack — a server-hosted deployment had no path at all, since
ScopedStack.create() (which every request through a server goes through)
refused both fields unconditionally, and the wire spec separately told
servers to always ignore them.

createdAt/updatedAt now follow the same owner-acting-alone tier already
used for hard delete, commitMigration(), and includeUnlisted:

- ScopedStack.create() accepts both fields only when the requester is the
  stack owner, undelegated, authenticated as themselves. A grantee, or a
  delegated app acting for the owner (either direction — delegating to the
  owner, or the owner delegating to someone else), is still refused with
  StackPermissionError.
- The existing id-vs-current-time skew check on ScopedStack.create() is
  skipped when createdAt is also supplied (only reachable by the owner),
  since Stack.create() below checks the id against createdAt instead —
  the "vs. now" check is for a live write, and a backdated one deliberately
  isn't.
- No new wire-side code: adapter-api already serializes the full record
  (Dates included) via JSON.stringify, so a client always sent these
  fields — what changes is server-side handling. A server built on
  ScopedStack inherits the owner-only enforcement automatically, the same
  way it already inherits entityId/principalId assignment.

Renamed UnscopedCreateRecordOptions to BackdatableCreateRecordOptions,
since ScopedStack.create() now accepts it too (conditionally). Updated
docs/spec/data-model.md and wire-format.md, and the changeset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKowhhMr6TnFEKsgaYtNYm
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createdAt/updatedAt can't be set, so importing an existing archive collapses it to one timestamp

2 participants