Skip to content

Establish and pin how a seed writes history - #64

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-32-seed-writes-history
Sep 1, 2026
Merged

os-warren merged 1 commit into
mainfrom
claude/issue-32-seed-writes-history

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #32

The answer is yes, with a second pass nobody would have guessed. No application-level workaround was needed and none was written.

The answer

{ context: { isSystem: true } } is the sanctioned way to write history — the same leg src/jobs/dispatch.job.ts already uses, and the leg the platform's own seed loader uses (SeedLoaderService.SEED_OPTIONS = { isSystem: true, skipTriggers: true, seedReplay: true }). It exempts a write from the readonly strip.

Column How you seed it Measured
completed_at carried on the insert ✅ works
last_update_at a second pass in mode: 'update' ✅ works — but an insert can never carry it

The stalled half is the one that would have bitten #7. beforeInsert stamps last_update_at unconditionally, and lifecycle hooks do run on the seed path — skipTriggers suppresses record-change automation, not hooks — so a system insert's value is overwritten with the boot clock. Measured on a real seeded boot: the row seeded with last_update_at: <45 days ago> came back holding boot time. It takes a second seed dataset on the same object in mode: 'update', matched on externalId, carrying only last_update_at; the beforeUpdate leg deliberately does not stamp on an administrative write, so that value lands.

Skip that pass and the "Not moving" view is empty on a freshly seeded demo, with no error anywhere — the seed reports success and the view is simply blank.

A third finding #7 needs

The seed loader resolves duly_task.owner as a natural key against sys_user.name, deferred to a second resolution pass. A bare id string matching no sys_user row does not resolve, and because owner is required: true the whole task row is refused (Owner is required, plus an unresolved-reference error). Measured: without a sys_user dataset seeded first, nothing seeds at all — inserted: 0, errored: 4.

Both directions are pinned

test/seed-history.test.ts runs against a real booted kernel with the declarative seeder actually running (skipSeedData: false), so the seed path itself is under test rather than a hand-made stand-in.

  • system-context insert of a done task with a completion instant → succeeds, nothing stripped
  • an ordinary caller's identical payload → still refused by completed_at_required_when_done (asserted on code: VALIDATION_FAILED + name: ValidationError + the message, not on a bare toThrow), and nothing is written
  • the same refusal with isSystem: false spelled out, so the exemption is pinned to the flag's value and not to the key being absent
  • a non-system last_update_at backdate → dropped, with droppedFields asserted as well as the unchanged stored value

Ablations

Both mutations were confirmed on disk before running (grep for the injected and the deleted text), and both were restored by an EXIT INT TERM trap.

Ablation Predicted Observed
drop readonly: true from completed_at (src/objects/task.object.ts) the two refusal tests go red exactly those 2 failed, 7 passed — the permissive assertions correctly stayed green
drop the mode: 'update' seed pass from the fixture the stalled assertion goes red 1 failed, 8 passed — expected '2026-09-01T08:09:31.664Z' to be '2026-07-18T08:09:31.478Z'

The second one is the important one: it proves the two-pass shape this PR documents is load-bearing rather than decoration.

Gates

All four green at 4f42406, which is the final commit:

pnpm validate   exit 0   (one expected warning: hierarchy-security provider absent — the documented state of this repo)
pnpm typecheck  exit 0
pnpm test       exit 0   Test Files 15 passed (15) · Tests 428 passed (428)
pnpm build      exit 0   Artifact: dist/objectstack.json (98.8 KB)

pnpm test was re-run after pnpm build with dist/objectstack.json on disk — still 428/428 — confirming the suite's artifactPath guard holds and the tests report on src/, not on the last build.

Files changed

  • test/seed-history.test.ts (new)
  • AGENTS.md — the answer, next to the product invariants

src/data/ was deliberately not touched. The worked example lives in the test fixture and in AGENTS.md rather than in dulySeeds, because rows added there would boot on every pnpm dev and collide head-on with #7, which owns the real seed. This keeps the file surface to test/ + AGENTS.md. No breach of the declared surface.

Filed out of scope

Generated by Claude Code


Generated by Claude Code

A duly_task cannot be created in `done` by an ordinary caller: completed_at is
readonly, beforeInsert stamps only last_update_at, so
completed_at_required_when_done refuses the row. Correct for the dispatch path,
fatal for the seed path that #7 needs.

Measured on @objectstack/runtime 17.2.0: `{ context: { isSystem: true } }`
exempts the readonly strip and IS the sanctioned way to write history — but it
takes two passes. completed_at rides along on the insert. last_update_at cannot:
beforeInsert stamps it unconditionally and lifecycle hooks still run on the seed
path (skipTriggers suppresses record-change automation, not hooks), so it takes a
second seed dataset in `mode: 'update'`. Without that second pass there are no
stalled rows and the "Not moving" view is empty on a seeded demo.

test/seed-history.test.ts pins both directions against a real booted kernel with
the declarative seeder actually running: the system write succeeds, and an
ordinary caller's identical write is still refused by
completed_at_required_when_done (VALIDATION_FAILED). It also pins that the
insert-path readonly strip is a protocol-BOUNDARY guard — engine.insert applies
none of it — filed upstream as objectstack-ai/objectstack#14147.

AGENTS.md carries the answer next to the product invariants, including that the
seed loader resolves duly_task.owner as a natural key against sys_user.name, so a
seed must seed its users first or every task row is refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 08:15
@os-warren
os-warren merged commit edbbd6c into main Sep 1, 2026
1 check passed
os-warren added a commit that referenced this pull request Sep 1, 2026
`pnpm dev` on an empty database now opens on a running system rather than
five empty grids — a three-level business-unit tree, thirteen people, a
twenty-item role catalog across three position codes, thirty-one duties and
six months of dispatched history.

History is produced by the dispatcher's own planner (`planDispatch`) rather
than by a second period walk, so every period key is the engine's spelling by
construction and "standing duties hold zero tasks" is structurally impossible
to violate rather than merely absent from the fixture.

`last_update_at` is written by a second `mode: 'update'` seed pass, per #32 /
PR #64 — an insert can never carry it, and without that pass the "Not moving"
view is empty while the seed reports success.

Fixes #7

Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
os-warren added a commit that referenced this pull request Sep 1, 2026
* Seed the demo: the product working on first boot

`pnpm dev` on an empty database now opens on a running system rather than
five empty grids — a three-level business-unit tree, thirteen people, a
twenty-item role catalog across three position codes, thirty-one duties and
six months of dispatched history.

History is produced by the dispatcher's own planner (`planDispatch`) rather
than by a second period walk, so every period key is the engine's spelling by
construction and "standing duties hold zero tasks" is structurally impossible
to violate rather than merely absent from the fixture.

`last_update_at` is written by a second `mode: 'update'` seed pass, per #32 /
PR #64 — an insert can never carry it, and without that pass the "Not moving"
view is empty while the seed reports success.

Fixes #7

Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Spread in-flight touch ages by how long a task has been open

Keeps every non-designated row inside the fortnight while putting real
values in the 7-to-14-day band, so the dashboard's nested >7d / >14d / >30d
tiles read 6 / 3 / 2 rather than 3 / 3 / 2.

Also corrects the fan-out comment after #72: the reason a seeded assignment
does not fan out is the loader's own skipTriggers, not an unbound trigger.

Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

A task cannot be created directly in done — decide whether that is the intent

1 participant