Skip to content

feat(engine+web): wire the discretionary journal — CLI-only in, self-reported everywhere out (#705) - #736

Merged
eaitbrahim merged 2 commits into
mainfrom
feat-705-journal
Sep 5, 2026
Merged

feat(engine+web): wire the discretionary journal — CLI-only in, self-reported everywhere out (#705)#736
eaitbrahim merged 2 commits into
mainfrom
feat-705-journal

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #705.

The journal table has been in the schema from the beginning with no repository method and no caller anywhere in the code. Dead schema is worse than no schema: a reader assumes a declared table is a used one.

The one record here that nothing can check

Everything else keel keeps is a machine's observation or a claim about the world — an order is what a venue reported, a transaction is a line out of a venue's own export, an asset attestation says PAXG is backed by allocated gold and a prospectus could contradict it.

"I felt rushed and broke my own rule" has no external referent at all. That isn't a defect — self-assessment is the only way this information exists, and no venue can produce it — but it means the record's value depends entirely on staying visibly separate from the ones that can be checked.

So self-reported is a sixth provenance in the timeline's closed vocabulary, not a reuse of human-attested. It carries WARN for the same reason simulated does: both mark a row that reads like evidence and is not.

The CLI is the only way in, and add takes no options

Not merely "it prompts by default" — a --emotion 3 would make the entry scriptable and leave the TTY gate guarding a ceremony that no longer needed a human to supply anything. A test asserts keel/web/api.py never reaches the writer.

_is_interactive, not _require_interactive_confirmation. The heavier gate demands a typed yes and exists for dangerous actions — its own docstring warns against ceremony without a matching threat model. Writing a sentence about your own trading isn't dangerous; it's unverifiable, which a confirmation prompt does nothing about. What is load-bearing is the terminal: off a TTY there is no human.

And there is no edit — no update method, pinned by name, because a journal you can go back and change records what you wish you had thought.

Blank is "did not say"

Every field but the timestamp may be skipped, including all of them. rules_followed=False is the operator confessing they broke their own rules, and bool(None) would print that confession over a question they skipped — three values, three words, on every surface. An emotion score off the 1–5 scale and an impact that isn't a number are refused, not coerced: a column holding "9", "very bad" and "3" has three vocabularies and no series.

Nothing an operator says about themselves comes back green

Every other green on this page is a fact. "I followed my rules" is the operator's opinion of the operator, and reflecting it back as a pass would be flattering them with their own words. NEUTRAL is the strongest honest reading, and a swept test asserts no state in this section is ever good — including a self-reported profit, where money()'s default would otherwise have graded the sign of a number the operator chose.

The entries are not the route's sortable collection — a refusal, not an omission. A journal reads forwards; sorted by dollar impact it becomes a ranking of the operator's own worst days. The Strathern rail, where the thing being ranked is a person.

On the same page as the other journal, deliberately

keel insights journal already exists and is a filterable view of closed trades. Separate pages would let a reader meet one without learning the other exists, so the discretionary entries ride /api/journal at data.notes, below the closed trades, with the marker on the heading and on every impact figure.

Chained into audit_events (#721) like everything else a human swore to, so it rides the audit export with a real row_hash beside the orders and fills.

Acceptance

  • Round-trip in one test: CLI add → web payload renders with the marker → CSV export carries self-reported
  • journal gains coverage; a test fails if it regresses to dead schema, and asserts the methods reach the real table
  • No web write path
  • No pre-filled emotion scores

Gates

  • 6,138 passed / 3 skipped; mypy clean on 441 files; ruff clean (the one E501 is pre-existing in docs/experiments/)
  • 12 mutants killed: a blank emotion stored as zero · a skipped rules question becoming a confession · an out-of-scale score kept as free text · an unparseable impact becoming zero · journal add no longer needing a terminal · the row filed as human-attested · bool(None) confessing · the entry going unchained · the limit keeping the oldest entries · the window closing at both ends · a self-reported impact that stops warning · a followed rule graded good

One shared-test improvement fell out: _ROW_ENDPOINTS now accepts a dotted payload path, so a collection nested under a key (data.notes.entries) is checkable without inventing a route for the sake of the test.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6

eaitbrahim and others added 2 commits September 5, 2026 17:05
…reported everywhere out (#705)

The `journal` table has been in the schema from the beginning with no repository
method and no caller anywhere in the code. Dead schema is worse than no schema:
a reader assumes a declared table is a used one.

THE ONE RECORD HERE THAT NOTHING CAN CHECK

Everything else keel keeps is a machine's observation or a claim about the world.
An order is what a venue reported. A transaction is a line out of a venue's own
export. An asset attestation says PAXG is backed by allocated gold -- a claim a
prospectus could contradict.

"I felt rushed and broke my own rule" has no external referent at all. That is
not a defect: self-assessment is the only way this information exists, and no
venue can produce it. But it means the record's value depends entirely on it
staying visibly separate from the ones that can be checked.

So `self-reported` is a SIXTH provenance in the timeline's closed vocabulary, not
a reuse of `human-attested`. Filing a self-assessment under the word this feed
uses for checkable human claims would put the one unverifiable record in the
database under a heading implying otherwise. It carries WARN for the same reason
`simulated` does: both mark a row that reads like evidence and is not.

THE CLI IS THE ONLY WAY IN, AND `add` TAKES NO OPTIONS

Attestations are human-sourced or refused. `keel journal add` prompts and
requires a terminal, and it accepts no value options at all -- not merely
"prompts by default". A `--emotion 3` would make the entry scriptable and leave
the TTY gate guarding a ceremony that no longer needed a human to supply
anything. A test asserts `keel/web/api.py` never reaches the writer.

`_is_interactive`, NOT `_require_interactive_confirmation`. The heavier gate
demands a typed `yes` and exists for DANGEROUS actions -- releasing a
kill-switch, spending money -- and its own docstring warns against ceremony
without a matching threat model. Writing a sentence about your own trading is not
dangerous; it is unverifiable, which is a different problem that a confirmation
prompt does nothing about. What is load-bearing is the terminal: off a TTY there
is no human, so cron, a pipe and a script are all refused.

And there is no edit. No update method, pinned by name, because the natural thing
for a later contributor to add beside two methods is a third that updates -- and
a journal you can go back and change records what you wish you had thought.

BLANK IS "DID NOT SAY", AND `rules_followed` IS THREE-VALUED

Every field but the timestamp may be skipped, including all of them: an operator
recording one sentence about one day must not have to invent an emotion score to
do it. A blank answer reaches the database as NULL, never a zero.

`rules_followed=False` is the operator confessing they broke their own rules --
the most consequential thing this table can hold -- and `bool(None)` would print
that confession over a question they skipped. Three values, three words, on every
surface.

An emotion score off the 1-5 scale and a dollar impact that is not a number are
REFUSED rather than kept. A journal whose emotion column holds "9", "very bad"
and "3" has three vocabularies and no series; one that read "lots" as zero would
say the day cost nothing.

NOTHING AN OPERATOR SAYS ABOUT THEMSELVES COMES BACK GREEN

Every other green on the Insights page is a fact: a fill happened, a chain
verified, an attestation is in date. "I followed my rules" is the operator's
opinion of the operator, and a surface reflecting it back as a pass would be
flattering them with their own words. NEUTRAL is the strongest thing that can
honestly be said, and a swept test asserts no state in this section is ever
`good` -- including a self-reported PROFIT, where `money()`'s default would have
graded the sign of a number the operator chose.

The entries are not the route's sortable collection, and that is a refusal rather
than an omission: a journal reads forwards, and sorted by dollar impact it
becomes a ranking of the operator's own worst days. The Strathern rail, in the
one place where the thing being ranked is a person.

ON THE SAME PAGE AS THE OTHER JOURNAL, DELIBERATELY

`keel insights journal` already exists and is a filterable view of closed TRADES.
Two things called a journal, and separate pages would let a reader meet one
without learning the other exists -- so the discretionary entries ride
`/api/journal` at `data.notes`, below the closed trades, with the marker on the
heading and on every impact figure. Both CLI surfaces say which they are.

Chained into `audit_events` (#721) like everything else a human swore to, so it
rides the audit export with a real `row_hash` beside the orders and fills.

Twelve mutants killed, including: a blank emotion stored as zero, a skipped rules
question becoming a confession, an out-of-scale score kept as free text, an
unparseable impact becoming zero, `journal add` no longer needing a terminal, the
row filed as human-attested, `bool(None)` confessing, the entry going unchained,
the limit keeping the oldest entries, the window closing at both ends, a
self-reported impact that stops warning, and a followed rule graded good.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6
…as truncating the journal

An independent review found eleven issues. Every one of them sat on a seam the
mutation battery had not reached: the client, and the limit.

ONE RECORD'S PAGE CONTROL WAS TRUNCATING A DIFFERENT RECORD

`read_journal` passed `?limit=` to both journals, so narrowing to one closed
trade silently hid 300 of an operator's 301 notes -- and the page said nothing,
because the payload shipped `shown_count` with no total and `render.js` never
read even that. Measured: 60 entries, `?limit=1`, one note served.

The rule is stated twice in the neighbouring docstrings this change should have
read. `journal_payload` already says a client showing "50 of 812" needs both
counts; `get_equity_points` says a caller that bounds a read is showing a WINDOW
and must say so, and names `count_equity_points` as how it learns what it left
out. There was no `count_journal_entries`, so the payload could not have said it.

The journal now has its OWN cap -- the coupling was a coincidence of two records
sharing a route -- and carries `total_count` beside `shown_count` plus the
sentence composed from them. Mutating the wiring back survived the whole 6,000-
test suite before this.

THE WHOLE SECTION COULD BE DELETED FROM THE CONSOLE WITH EVERY GATE GREEN

Two independent deletions survived the full suite: the call to `notesSection`,
and the SELF-REPORTED pill. The acceptance test claimed to cover "the console
that renders it" and in fact called the payload function directly, and the
parity scan cannot help -- an uncalled function still reads the keys it reads.
That is the third time this series a client-side pin has needed a mutant to show
it was pinning nothing.

`--limit 0` PRINTED "No journal entries yet" OVER A JOURNAL

And `--limit -1` printed everything, because SQLite reads a negative LIMIT as
unbounded. `keel/web/api.py::_journal_limit` names this exact hazard for the
other journal on the same route. Refused in `gather_journal` rather than only at
the click option, so the guard travels with the function instead of one caller.

THE "NO WEB WRITE PATH" TEST GUARDED A FILE WHERE A WRITE COULD NOT LIVE

It grepped `keel/web/api.py`, whose own docstring says "Reads only. Not one route
below answers a POST". The write surface is `keel.commands.setup.ACTIONS`, the
only thing `server.do_POST` routes to -- and it ALREADY carries a
human-attestation writer (`attest_asset`), which is precisely why a "quick note"
box is the plausible next addition. Adding a journal writer to `setup.py` passed
the old test untouched.

THE EXPORT DROPPED EVERY SENTENCE AFTER THE FIRST

`errors_made` and `chart_note` were an if/elif, so an entry carrying both
exported only the error, and `screenshot_ref` reached the file in no column and
no summary. This row is the journal's WHOLE representation in a CSV an operator
hands to an auditor -- there is nowhere else any of it could reappear.

AND THE READ INVENTED A THIRD SPELLING OF AN IDIOM TWO SIBLINGS SHARE

`get_equity_points` and `get_cycle_balances` both cap with the same re-ordered
subquery, and one names the other as canon. This used a DESC read reversed in
Python "without a subquery" -- behaviourally equivalent for a positive limit, and
exactly where the negative-limit bug came from.

Also: the blocklist test for `journal add`'s options is now
`journal_add.params == []` (a `--feeling 3` walked past six literal names); the
append-only pin sweeps the package for `UPDATE journal`/`DELETE FROM journal`
rather than three method names; `_optional_text`'s empty-string rule is tested; a
stray `#:` attribute comment above a `def` is gone; and `_function_body` now
finds module-private functions, which is why `notesSection` could be scanned at
all.

TWO THINGS NAMED RATHER THAN CLAIMED

The module docstring said the TTY gate means "nothing can reach past it". A
`pty.fork()` driver runnable from cron allocates a real terminal and this command
answers it. True of every gate built on the same predicate; the honest statement
is that the check makes automated entry something someone has to MEAN.

And `test_the_limit_breaks_a_timestamp_tie_by_id` pins the outcome, not the `id
DESC` clause: measured on this SQLite build, dropping it returns the same rows.
The clause stays because the sibling states why, but no test here can make its
absence fail, and the test says so.

Thirteen mutants killed of fourteen tried; the fourteenth is the tie-break above,
recorded in the test as a named blind spot rather than left implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6
@eaitbrahim
eaitbrahim merged commit 9ce9c1b into main Sep 5, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-705-journal branch September 5, 2026 22:17
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.

[engine] Wire the discretionary journal: repo methods, CLI entry (human attestation), web read view

1 participant