Status: Implemented (Format Level 2, see bundle-format.md) — the event vocabulary, JSONL sidecar IO, cross-checks, and projections live in
packages/schema/src/journal*.tsandprojections.ts; the app dual-writes vialib/data/emit-events.ts, and the CLI reads/writes the sidecar (arkaik log,arkaik release). This document remains the normative contract. The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119.
Purpose
The journal turns status from a mutable field that forgets into a tracked evolution. It is an append-only log of typed events recording how the product graph changed: status transitions, releases, ideas, requests, reference updates. From it, consumers derive the features a static snapshot cannot offer — per-node timelines, changelogs, release notes, and a backlog of ideas and requests.
Storage Shapes
Canonical (repo): JSONL sidecar
In a repository (Kommit mode), the journal is a sidecar file next to the snapshot — never inside it. The snapshot stays small and the history cannot corrupt it.
docs/arkaik/
bundle.json # snapshot (Levels 0–2)
journal.jsonl # append-only, one event per line
journal/
archive-1.3.0.jsonl # compacted history per release (optional)
assets/ # repo-hosted asset files
arkaik init configures .gitattributes:
docs/arkaik/journal.jsonl merge=union
JSONL is the one shape where git's union merge is sound: one line = one self-contained event, concurrent appends merge automatically, and order is recoverable from the events themselves. A malformed line invalidates exactly one event — the validator reports the line number — and can never damage existing history or the snapshot. This is the property that makes agent writes safe: appending is structurally incapable of the corrupt-the-whole-JSON failure that whole-file regeneration invites.
Interchange: embedded journal[]
When a single file is needed (drag-in import to the app, Publik, LLM output), arkaik pack embeds the journal as the bundle's optional journal array. This is a projection for transport, not a storage recommendation. Publik publishes without the journal by default — history stays private unless explicitly included.
Event Envelope
One event is one JSON object:
interface JournalEvent {
id: string; // ULID — sortable, collision-free without coordination
ts: string; // ISO 8601 timestamp
actor?: string; // who/what wrote it: "alexis", "claude-code", "arkaik-sync", "ci"
type: string; // vocabulary below
// ...type-specific payload fields, flat on the object
}
| Rule | Detail |
|---|---|
| Ordering | Consumers MUST order events by ts, tiebreaking by id. Files MAY contain out-of-order lines (union merge reorders); consumers MUST tolerate this |
| Forward compatibility | Unknown type values and unknown fields MUST be preserved on rewrite and ignored on read. The vocabulary grows without version bumps; a per-event v field is reserved for the day an existing payload shape must change |
| Payload discipline | Events MUST NOT embed asset payloads (screenshots, images). node.updated records changed field paths (and old/new values only for short scalar fields like title), never blob contents |
| Project scope | Events carry no project_id; scope is implied by the file they live in / the bundle that embeds them |
Event Vocabulary (v1)
| Type | Payload | Meaning |
|---|---|---|
node.created | node_id, species, title | Node added to the graph |
node.updated | node_id, fields[], optional from/to for scalars | Non-status fields changed |
node.status_changed | node_id, from, to, platform? | Lifecycle transition; platform present when a per-platform view status moved. Historical events MAY carry the pre-v3 ids prioritized/blocked in from/to; validators MUST accept them — history is never rewritten |
decision.status_changed | node_id, from, to | A decision moved between decision states (metadata.decision_status); from/to are decision-status ids, not lifecycle ids |
node.deleted | node_id | Node removed. Implies cascade removal of every edge referencing it — writers do not emit the cascaded edge.removed events, and consumers/validators MUST apply the cascade |
edge.added | edge_id, source_id, target_id, edge_type | Relationship created |
edge.removed | edge_id | Relationship removed (non-cascade) |
release.tagged | version, notes?, platform? | A version shipped. platform optional: absent = project-wide; present = that platform's release rhythm |
deliverable.shipped | deliverable_id, title, summary?, url?, node_ids?, platform?, lab_note? | A unit of shipped work (typically one merged PR): entity changes + a summary note. Re-appending with the same deliverable_id edits — consumers resolve content latest-wins, anchored at the first occurrence |
idea.proposed | title, description?, node_id? | An idea, before (or linked to) any node |
request.filed | title, description?, source?, node_id? | An external ask (user feedback, stakeholder request) |
ref.added | node_id, ref_id, ref_type, url | External reference attached |
ref.removed | node_id, ref_id | External reference detached |
ref.status_changed | node_id, ref_id, from?, to, synced_at | Mirrored external status moved (issue closed, PR merged) |
journal.baseline | node_ids[] | This journal's coverage begins here: the listed nodes already existed and their creation is not recorded in it. A writer emits exactly one, immediately before its first append to a journal that does not cover the whole snapshot (a bundle exported from the app, hand-authored, or pre-journal). It is a statement about coverage, never fabricated history — the alternative, backfilling a node.created per node, would invent events nobody witnessed |
quality.audit.completed | audit_id, framework_version, commit?, scores?, counts? | A Kritik audit run landed. scores[surface][domain] is a 0-100 domain score; counts tallies open findings by severity. The full evidence stays in the bundle's quality section — the event carries what a feed can summarize |
quality.finding.opened | finding_id, criterion_id, surface, severity, priority, title, node_ids?, issue_url? | A quality finding was retained after the adversarial verification pass. node_ids ties it to the graph exactly as deliverable.shipped does |
quality.finding.resolved | finding_id, resolved_by?, node_ids? | The fix merged; resolved_by is the PR or commit URL that closed it |
quality.finding.accepted | finding_id, reason, node_ids? | The risk was accepted as known and owned, recorded away from a checkout (issue #400) — in a repo, acceptance is written into the findings file instead and no event is emitted. The read derives accepted-risk from it |
quality.signal.tripped | criterion_id, surface, signal, commit?, detail? | A criterion's mechanical monitoring check failed between two audits |
The five quality.* types are Kritik's (docs/rfcs/kritik.md § 3.2). They follow the same rule as every other event — facts, not state: current quality is a projection (deriveQualityMatrix), the latest audit plus opened-minus-resolved-or-accepted findings. Writers SHOULD set actor on them so human, agent, and CI scores stay tellable apart; validateBundle warns when one does not, and never blocks. A trip written from CI against a hosted project carries the commit it observed and, by convention, its run URL in detail — the one writer that always knows both, and pays nothing to say so (issue #406).
Authority & Consistency Model
The journal is not event sourcing, and v1 makes no replay promises. The rules:
- The snapshot is authoritative for current state. The journal is authoritative for history.
- Writers (the skill, the CLI, later the app) dual-write: patch the snapshot and append the matching event in the same change.
- The validator cross-checks the two by value, never by timestamp (per-node timestamps don't exist and clocks lie): the last
node.status_changed.tofor a node must equal its currentstatus; every node has anode.createdor is named by ajournal.baseline; no event references a node or edge that never existed (a baselined id counts as having existed). The baseline is what makes adoption possible: a journal that starts life after the graph does would otherwise be flagged for every pre-existing node the moment its first event lands, so a writer MUST emit one covering the uncovered nodes before its first append, and MUST NOT backfillnode.createdevents instead. Any mismatch is a validation error naming both sides. The same rule extends to decisions: the lastdecision.status_changed.tofor a node must equal its currentmetadata.decision_status(absent reads asproposed). A node with at least onedecision.status_changedevent but no longer present in the snapshot (legitimately deleted after its last transition) is not cross-checked. - Divergence is repaired explicitly:
arkaik doctorappends corrective events to make history consistent with the snapshot (the snapshot wins). Consumers MUST NOT silently re-project the snapshot from the journal — that would launder drift instead of surfacing it.
Releases, Compaction & Growth
release.taggedevents are the version markers. The changelog between two versions is the ordered slice of events between their markers; a release note is that slice summarized (by template or by an agent), filtered to the platforms of the affected nodes whenplatformis used.- Deliverables sit between events and releases: a
deliverable.shippedrecords a unit of shipped work (typically one merged PR) with a summary note, aurl, and the touchednode_ids. A deliverable belongs to releaseVwhen its first occurrence falls insideV's changelog slice; the first occurrence anchors when it shipped, and later re-appends with the samedeliverable_idedit content (latest occurrence wins) without moving it between releases. A first occurrence after the last marker is unreleased. There is nodeliverables[]list onrelease.tagged— the slice is the grouping. A deliverable born from a merged PR's Lab Note carries the full note underlab_note(enrequired,fr/suggested/nodesoptional) — the bilingual half exists so downstream projections (the pollen feed) keep both languages. A note'snodesare the ids the author declared; they are checked against the snapshot and unioned with the acceptances the pull request mentions to formnode_ids, so the event's own list never names a node the graph does not hold. arkaik releasetags the version, generates the release-note draft, and MAY compact: move the released slice fromjournal.jsonltojournal/archive-{version}.jsonl. Archives are part of history (projections may read them); the working journal stays small. Compaction differs from the changesets tool's model deliberately — changeset files are consumed at release, journal history is kept.- Because compaction relocates history rather than dropping it, validators MUST fold
journal/archive-*.jsonlin alongsidejournal.jsonlwhen a bundle carries no embedded journal: the archives are the journal. Reading only the working file makes an ordinary release look like data loss — the first release archives everynode.created, and any later one can archive a node's newest transition and strand an older one as the apparent "last" — so the cross-check would fail a perfectly healthy project. A malformed archive line is the same hard error as a malformed sidecar line, reported with its file and line number. An embeddedjournalarray still wins outright; the packed interchange form carries its own history. - The hosted app stores journals under a separate storage key from the snapshot store, so history growth never inflates every snapshot write. App-side event emission is gated on the IndexedDB migration for the same reason (see the roadmap in vision.md).
Projections
Projections are pure functions over (snapshot, journal) — the same pattern as the existing status rollups in lib/utils/platform-status.ts. Planned module: lib/utils/journal.ts.
| Projection | Answers | Surface |
|---|---|---|
| Node timeline | "How did this view get to live on iOS?" | History section in the node detail panel |
| Changelog | "What changed between 1.2 and 1.3?" | Project-level journal/changelog view |
| Deliverables | "What units of work shipped, and in which release?" | Changelog page — one section per milestone, deliverables as its timeline; arkaik release draft grouping |
| Commitments | "What moved from idea to committed work?" | Design page (backlog → commitments → decisions) |
| Release notes | "What do we tell users shipped?" | Generated draft at arkaik release |
| Backlog | "Which ideas and requests are open?" | Ideas/requests list (an idea.proposed is open until a linked node exists or a resolving event closes it) |
A bundle without a journal simply renders none of these — the empty state, not an error. That is the whole backward-compatibility story.