Skip to content

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

Description

@cuibonobo

Problem

Stack.create() stamps createdAt and updatedAt from new Date() unconditionally (packages/core/src/stack.ts), and the wire refuses client-supplied timestamps by design (docs/spec/wire-format.md § Records). There is therefore no supported way to import an existing corpus with its real dates: every record lands stamped with the import moment, which destroys chronological ordering, feed dates, archive pages, and any date-range query over the imported data.

This is not a niche path. "Take your data with you" is the project's stated purpose, and the inbound direction currently loses the one fact that makes an archive an archive. Every migration into a stack from anywhere hits it.

The sharpest framing: the ID already carries this

Record IDs are Crockford base-32 with a 9-character millisecond prefix, they are client-mintable by design, and the ID is the sort key. docs/spec/data-model.md § Record IDs is explicit that the prefix "decodes to the creation millisecond exactly."

So core already lets an owner choose a record's creation position. It just won't let the createdAt field agree with it — and then the two can silently disagree, with nothing detecting it.

That makes this an inconsistency to close rather than a new capability to grant, which is a considerably smaller thing to argue about.

Proposed scope

  • Stack.create(typeId, content, { createdAt, updatedAt })unscoped Stack only.
  • Never on ScopedStack. The ID timestamp-skew check exists precisely because a grantee could otherwise forge a sort position; a settable createdAt there would route straight around it.
  • Never over the wire. POST /records keeps refusing both, unchanged.
  • Invariant: supplying an id and a createdAt that disagree beyond the configured skew tolerance is an error, not a silent divergence. The whole point is to make the two agree.
  • updatedAt matters as much as createdAt. The create-then-update workaround bumps the record to version 2 and stamps updatedAt: now, so one without the other doesn't solve the problem — and it also pollutes version history with a fake revision per record.

Alternative considered

Write below Stack directly to the record adapter during import, constructing full records by hand. It works — adapter.createRecord(record) takes the whole record — and it is what an importer has to do today. But it skips content validation, DID-binding checks, reserved-key rejection and attachment mimeType establishment: exactly the invariants docs/spec/adapters.md says live in Stack so every adapter inherits them. A supported option keeps imports on the validated path instead of teaching every importer to go around it.

Concrete driver

The cuibonobo.com migration — years of dated posts moving from D1 into a stack. Record IDs carry over unchanged (core's id.ts is a direct port of that project's, same alphabet and layout), so the ordering is recoverable from the IDs; the timestamps are not.

It is most acute for short-form published entries, which under the commons' own clock rules carry no content date at all — message@1 has no date field because "meaningful time is createdAt" — so native createdAt is the only ordering key they will ever have. A commons post@1 proposal with the same property is open separately.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions