decision-journal / references/digest-and-disclosure.md
Digest and disclosure: rendering a journal for someone who cannot read all of it
A reference shipped with the skill · 3,105 words
Digest and disclosure: rendering a journal for someone who cannot read all of it
A journal accumulates entries an agent wrote for itself, mid-session, with whatever candour that required. Nothing in that origin makes every entry fit for a document a team reviews or a customer sees. Disclosure is the control that lets the same journal serve both audiences without either editing history or hiding that anything was withheld.
The three classes
private → team → published
Spec §13.3 orders them most closed to most open. Every entry carries exactly one.
| Class | Shows up in a digest rendered at --level … |
|---|---|
private |
Only private. Never team, never published. |
team |
private and team. Not published. |
published |
private, team and published — the version meant to leave the room. |
An entry's class is readable at a requested level when the class is at least as
open as the level: readableAt(entry, level) is rank(entry.disclosure) >= rank(level). So a published digest — the default — shows only published entries; a
team digest shows team and published; a private digest, meaningful only as an
internal, unredacted view, shows everything. More open classes are supersets of more
closed ones, never the reverse.
Setting it
agent-journal record --workspace api --kind decision --id dg-bound \
--question "how do we bound the retry queue?" --chosen "in-process ring buffer, 256 entries" \
--disclosure published
--disclosure takes record and invalidate, nowhere else — it is a property of an
entry, not of a workspace or a query. An unrecognised value — --disclosure public, say
— is refused outright, exit 2, naming the three real classes in the error rather than
silently containing it to private; see the next section for why that refusal, not a
quiet downgrade, is the correct response here.
The write default and the parse default disagree, on purpose
Omit --disclosure on a write this CLI performs and the entry lands as team. Read a
record — any record, including one this same CLI wrote — where the disclosure field
is absent or holds something unrecognised, and it parses as private.
Those look contradictory side by side, and the plan they came out of said so directly: say this, because it is the subtlest asymmetry in the disclosure design, not an inconsistency to quietly reconcile.
They are not the same decision. They answer two different questions:
- Writing, with a caller present: what did this caller mean? A caller who typed
nothing meant the ordinary case, not silence —
team, shareable inside the organisation but not shipped externally. Silently downgrading their unstated intent toprivatewould be the same coercion this CLI already removed once from--author. - Parsing, with no caller to ask: what can this record be assumed to permit? A
line read from disk — possibly hand-edited, possibly from a version of this tool that
never had the field, possibly corrupted — carries no author to consult. Here the two
mistakes do not cost the same: treating an ambiguous record as more open than intended
publishes something that should have stayed contained, and that is not a mistake a
later correction undoes. Treating it as more closed than intended just means someone
notices an omission and asks.
privateis the only default that fails toward the recoverable error.
The same instinct governs Capabilities elsewhere in this package — preserve unknown
rather than assume availability — and lands on the opposite literal here because the
harm runs the opposite direction: understating a capability costs nothing but useful
information; overstating disclosure costs the entry's own contents.
Disclosure is containment, not curation
It is tempting to describe private/team/published as an editorial ladder — draft,
reviewed, publishable — the way a document workflow might. Resist that. Spec §6.4
rejects "curated in-repo" as a description of any part of this journal precisely because
it names no curator and no rule: nobody is elected to decide what counts as ready, and
nothing enforces that a published entry was ever reviewed by a person before a digest
picked it up.
Disclosure names where an entry may be exposed, never whether it is any good. A
published entry can rest on model_knowledge alone, contradict what a later entry
says, or belong to a decision that gets invalidated an hour later — the digest will
still show it to anyone entitled to see published material, flagged and ordered like
any other entry, because disclosure and quality are unrelated axes. Confusing them would
recreate exactly the unaccountable, undocumented gate the spec calls out by name.
A private entry never reaches a digest — but invalidation is never gated
renderDigest computes outcomes over every event passed to it — project(events)
runs before the disclosure filter, not after — and only then filters which entries make
it into the printed list. The consequence: an entry's disclosure controls whether its
own text is shown, never whether the fact it recorded took effect.
Concretely, a private invalidate still suppresses its target for a team or
published reader. The retraction's reasoning stays hidden; the fact that the target is
now invalidated does not.
agent-journal record --workspace api --kind decision --id d-secret-source \
--question "how do we route the queue's dead letters?" --chosen "a side table" \
--disclosure published
agent-journal invalidate d-secret-source --workspace api \
--reason "the vendor named in the postmortem cannot appear in a published digest" \
--disclosure private
agent-journal digest --workspace api --level team
The team-level digest shows d-secret-source with outcome: invalidated — the
question is still there, the fact that it was retracted is still there — and nowhere in
that output does the withheld reason appear. Rendering it at --level private is the
only way to see the retraction record itself, title and reason included.
Get this backwards — filter by disclosure first, project second — and a private retraction stops suppressing anything for a less-privileged reader: the invalidated entry would read as live, which hides that anything happened at all. That is worse than a retraction with a hidden reason, because a hidden reason at least announces itself as hidden.
Ordering: by consequence, not by time
A digest is not a timeline. Entries are sorted so the reader hits the things that most change what they should do first:
invalidatedoutcome before anything else.- Among the rest, by
reversibility:one-way, thenhard, thenmoderate, thentrivial, then entries with noreversibilityat all. - Within a tie on the above, an entry with a
blastRadiusbefore one without. - Any remaining tie breaks on
id, lexicographically.
That fourth rule exists so two renders of the same journal are byte-identical — a committed digest that reordered itself on every regeneration would make review diffs noise instead of signal.
rejected[] renders in full for every decision that has it; nothing is truncated or
summarised. An entry whose influences are only model_knowledge — not merely one
of several — gets a visible flag:
> **No source consulted** — this rested on `model_knowledge` alone.
A mixed entry, one model_knowledge influence alongside a real citation, does not carry
that flag: the signal is "nothing checkable was consulted," not "priors were involved at
all."
Every digest carries a coverage statement
The tail of every rendered digest is the same coverage block agent-journal coverage
produces on its own, folded in rather than left for the reader to cross-reference
separately:
## Coverage
- sessions observed: 2
- sessions that recorded nothing: 0
- sessions with no events at all: not assessed
- refused writes (voids): 0
- sequence gaps: 0
- downgraded anchors: not assessed
`not assessed` is not `none` — it means nothing computed the figure.
sessionsWithNoEvents and downgradedAnchors are the two fields this package will only
ever compute when a caller supplies extra input the CLI's plain digest invocation does
not (a known-sessions list; a retention pass). Where that input was never supplied, the
field is null, and the digest renders not assessed — never 0. A 0 there would
claim a real count of zero; not assessed says nobody looked, and a reader can tell the
difference without cross-referencing anything.
digest refuses on a damaged journal; every other command warns and continues
coverage, show and trace all take the same stance toward a journal they could not
fully read: they render whatever they could parse, exit non-zero, and print a warning
that the result is a floor, not a total. digest does not. A damaged journal makes it
refuse outright — no partial output on stdout, and --out writes nothing to disk. Where
one unreadable path and no malformed lines makes coverage print its report with a
warning appended, the same damage makes digest --out <path> print nothing at all,
write nothing to <path>, and exit 1 with refusing to render: <N> unreadable path(s), <M> malformed line(s) on stderr.
The other commands exist to be read interactively, by someone who will also see the warning line next to the JSON. A digest is different in kind: it is the artifact that gets committed, attached to a review, or handed to someone who was not in the room when it was generated — and by the time they read it, the warning that would have explained its gaps may not be attached to it at all. A digest that renders successfully with a warning invites exactly that: a partial document with no visible sign, once separated from its stderr, that it is partial. Refusing outright is the only version of this command that cannot produce a silently-incomplete artifact.
--out refuses to write inside any workspace's segment tree
Point --out at a path under $AGENT_JOURNAL_ROOT/workspaces/<id>/segments/ — the tree
agent-journal itself writes entries into — and the command exits 2 before rendering
anything, naming that directory and explaining why: a digest written there becomes
journal input on the next read.
Every read of a workspace walks every .jsonl file under its segments/ tree. A digest
written into that tree becomes journal input the next time anything reads the workspace
— parsed as an entry, or as a malformed line if its extension happens to match and its
content does not parse as one, either way corrupting the record it was rendered from.
This is not scoped to the workspace named by --workspace. --out may name a
different workspace's segment tree —
agent-journal digest --workspace ws --out $AGENT_JOURNAL_ROOT/workspaces/other/segments/p.jsonl
— and that sibling is refused too, even one that has never been written to before and
has no segments/ directory on disk yet. Only a path outside every workspace's segment
tree is left alone; writing at $AGENT_JOURNAL_ROOT/digest.md, or elsewhere under a
workspace's own directory that is not its segments/ tree, is odd but harmless and is
not refused.
The check resolves both the segment root and --out through the filesystem —
realpath on the parent chain, lstat on the final component — not string comparison,
so a relative path, a .. traversal, or a symlink pointing back into the tree is caught
the same way a direct path would be. That includes a dangling symlink — one whose
target does not exist yet, the shape ln -s $ROOT/workspaces/ws/segments/poison.jsonl ./out.md produces. realpath alone cannot resolve a target that is not there yet, so
the check follows the link itself (readlink, resolved against the link's own
directory) rather than treating "the target doesn't exist" as "this whole path doesn't
exist, so it must be fine": both are refused identically, exit 2, before anything is
written.
--out refuses at --level private
Spec §13.3, verbatim: "private never leaves the local journal — not to sync, not to a
hosted sink, not to a digest." Printing a private-level digest to stdout is reading the
local journal; writing it to a file is leaving it. So --level private combined with
--out is refused outright, exit 2, before anything is read or rendered:
$ agent-journal digest --workspace api --level private --out ./digest.md
--out is refused with --level private; private entries are not written to a file
(spec 13.3) — omit --out and read the digest from stdout for local inspection
Drop --out and the same --level private render still works — stdout is local
inspection, not leaving the journal, and stays available:
agent-journal digest --workspace api --level private
A digest is a rendered artifact, never the source of truth
The journal's segment files are the record. A digest is a projection of them at one
disclosure level, at one moment — literally stamped As of <timestamp> on its first
line. Treat a stale digest as evidence of anything beyond what the journal looked like
when it was rendered, and you have reintroduced the exact problem coverage exists to
name: an artifact that looks authoritative and is quietly out of date.
When to render one: with the pull request that caused it
§17.4 left the cadence undecided, and noted what that costs — "an undecided cadence means no digest reaches anyone." Everything upstream of §6.4's committed digest works and lands nowhere. Decided 2026-09-09: per pull request.
Before opening a PR that changed something worth reconstructing later, render the digest into the repo and commit it with the change:
agent-journal digest --workspace api --level published --out docs/decisions/api.md
It lands where a reviewer already is, while the entries are still fresh enough to argue with. Per release batches too much into one document to be read; on demand means nobody runs it.
Why CI cannot do this for you
It is worth knowing why this step is yours rather than a job's. §6.2 keeps the journal
at ~/.agents/journal/ — on the machine that wrote it, deliberately outside the repo,
because segments carry hostnames and home paths. A CI runner has no journal to render
from. A workflow calling digest would produce an empty document, which is worse than
none: it reads as "no decisions were made".
What CI does instead is validate what was committed. The
source repository carries a digest
checker in its own verify chain — it is not part of this skill's installed files — and
it confirms that every digest under docs/decisions/ is intact: not empty, not
truncated mid-write, still carrying its ## Coverage block, with nothing appended by
hand and no second forged coverage block above the real one. Any repository adopting
this cadence wants the same check; the shape above is what it looks for.
It cannot check freshness, and does not claim to. A passing run means "this digest is intact", never "this digest is current" — the runner has no journal to compare against. A digest that is three weeks stale passes every check. That is a real limit of this arrangement, and the reason the rendering step above is a habit rather than a gate.
Tracing from a symptom: trace <key> --workspace <id>
Spec §10.2 is explicit that support and debugging questions do not start with an entry
id — they start with a ticket number, a file that misbehaves, a flag someone flipped, or
a decision id someone already has in hand. trace indexes all four the same way:
| Source | Example |
|---|---|
id |
the entry's own id |
subject |
--subject src/queue.ts on the entry that recorded it |
| anchor ref | --anchor runtime:flags/retry-backpressure=on |
| influence ref | --influence url:supporting:JIRA-4821 |
agent-journal record --workspace api --kind decision --id d-queue \
--subject "src/queue.ts" \
--question "how do we bound the retry queue?" --chosen "in-process ring buffer, 256 entries" \
--disclosure published
agent-journal record --workspace api --kind finding --id f-ticket \
--claim "the bound matches the SLA in the ticket" \
--influence url:supporting:JIRA-4821 --disclosure published
agent-journal record --workspace api --kind decision --id d-flag \
--question "should retry backpressure ship dark?" --chosen "yes, behind a flag" \
--anchor "runtime:flags/retry-backpressure=on" --disclosure published
agent-journal record --workspace api --kind finding --id f-depends \
--claim "the 256 bound is never reached" \
--influence journal:decisive:d-queue --disclosure published
agent-journal trace src/queue.ts --workspace api
agent-journal trace JIRA-4821 --workspace api
agent-journal trace flags/retry-backpressure=on --workspace api
Each reports one match: d-queue via subject, f-ticket via influence, d-flag via
anchor. Every match carries via, naming which of the four sources produced it, in
precedence order id > subject > anchor > influence when more than one would
apply to the same entry — an entry whose id happens to equal the key is what was asked
for; that it might also cite the key elsewhere is secondary and never shown instead.
agent-journal trace d-queue --workspace api shows this with two entries at once: it
reports d-queue itself via id, and, separately, f-depends via influence — the
same key, matched for two different reasons, each labelled with the reason it matched.
Lookup is exact, never a substring
agent-journal trace queue --workspace api
This reports no match — exit 0, an empty matched array, and
no entry is indexed under "queue" in this workspace on stderr — even though the
workspace above has an entry whose subject is src/queue.ts. Keys are trimmed and
lower-cased, but never partially matched — a fuzzy index would hand back candidates a
reader then has to rule out one by one, which is the adjudication this lookup exists to
avoid. Ask for the exact string that appears in the subject, the anchor, the influence,
or the id.
The walk goes backwards, and does not stop at an invalidated entry
Past the initial match, trace follows three edge types backwards — journal-typed
influences, supersedes, and invalidates — building a chain that says, for every
step past the first, which edge led there:
agent-journal invalidate d-queue --workspace api \
--reason "the SLA changed; the bound is no longer sufficient"
agent-journal trace f-depends --workspace api
{
"matched": [{ "id": "f-depends", "via": "id" }],
"chain": [
{ "id": "f-depends", "via": null },
{ "id": "d-queue", "via": "influences" }
]
}
d-queue is now invalidated, and the walk reaches it anyway. That is deliberate, not
an oversight the disclosure work should have closed off: "why is this like this" very
often ends at a decision that turned out to be wrong, and hiding invalidated entries
from the walk would make the one traversal built for root-causing unable to reach root
causes. trace answers a different question than show's live flag — reachable,
not currently in force — and conflating the two would break the tool support questions
are supposed to use.