Repository navigation
v0.21.0
Added
-
The journal is bounded, chained and checkable; a shortening
action_logwrite leaves a copy (#758, phase 6). Three ways the audit
trail could quietly stop being one, closed together. Rotation:
JOURNAL.jsonland eachhistory/reports/<kind>.jsonlgrew without a
ceiling, so a long-lived workspace paid for its whole history on every
read. A file that reaches 32 MiB (MUREO_JOURNAL_MAX_BYTESto change it;
anything but a positive integer is refused with one warning) is renamed to
<name>.<UTC stamp>.<suffix>before the line that filled it is written,
and a fresh file is started. Nothing is ever deleted — the bound is on
one file, not on the record — and every reader follows the whole set:
mureo journal --all,read_report_history, and thejournalsource of
mureo_history_query, whose 20 000-line scan cap is now spent newest file
first and counted across them. A hash chain: every journal record ends
withprev(SHA-256 of the previous physical line) andh(SHA-256 of the
record withouth), so an edited line fails its own hash and a removed or
spliced one fails the next line'sprev— across a rotation too, since the
first line of a new file points at the last line of the retired one. The
honest limit is documented and pinned by a test: truncating the TAIL is not
detectable by a backward-pointing chain, and mureo does not claim it is.
mureo journal --verifywalks the live file and its rotated siblings as
one chain, prints a line per file and the verdict (chain ok/
chain BROKEN at <file>:<line> (<why>), plus anyunchainedpre-phase-6
records), and exits 0 / 1 / 2 (ok / broken / no journal);--jsonreturns
the report as an object, and the filter flags are ignored — a chain is a
property of the file, not of a selection from it. Theaction_logbackup
rule:action_logis append-only by contract and every targeted mutator
honoured it, but a whole-document write (StateStore.write_state, a host's
import or restore, a repair) could shorten or rewrite it with nothing left
behind.write_state_filenow backs the on-disk document up to
STATE.json.bak.<unix_ns>and warns with both lengths whenever the incoming
log is shorter than, or rewrites, the one on disk. The write still proceeds
— a document that cannot be written is a workspace nobody can repair — but a
backup that FAILS stops it, and the ordinary locked mutators hand the guard
the document they already read, so they pay no second read. No hash chain on
thehistory/files: they are derived data, while STATE.json and the
journal are the records of truth. -
mureo_history_query— one bounded read over every trail (#758, phase
4b). Four records answer "what happened before today" and each had its own
door:action_logand thedailywindow only inside a whole
mureo_state_get, the journal only through themureo journalCLI, and the
phase-4a archives through no tool at all — so an agent on a host without a
shell could not ask "what did we change on this campaign in July", and one
that could had to read a whole document to find out. The new tool takes
sources(action_log,journal,daily,reports; the first two by
default), thesince/untilUTC window,platform,campaign_id, the
entity_type+entity_idpair,batch_id, and the journal-onlytool,
outcome,mutations_onlyandfailures_only.limit(default 50, max
200) applies per source, the newest matches are kept, and every section
states whether more matched than it returned — a partial answer says so
rather than reading as a complete one.action_logentries carry their
index in the FULL log (the indexrelated_actionsandevaluation_of
refer to);dailymerges the archived months with the days still in
STATE.json into one series, the document winning a day held in both; the
journal is read only to its last 20 000 lines, reported asscanned_lines,
because nothing rotates it yet, and adailyquery with nosincereads
the archive back 12 months fromuntil(or today) rather than opening every
month a years-old account ever had — that section'swindowreports the
dates it answered from anddefaulted: true, and an explicitsince
reaches as far back as it is given. Read-only: the tool takes no call-level
reasonand appends no strategy reminder. A missing STATE.json or journal
is answered, not raised. -
Past-due observations close themselves (#758, phase 5). An
action_log
entry with anobservation_duestayed open until an agent remembered to
append anevaluation_ofrecord for it —mureo_outcome_evaluateis pure
and writes nothing — so a forgotten one was re-evaluated on every run while
the pending set grew without bound.mureo_state_getnow scores every
past-due entry whose outcome its own document determines (a campaign-level
action with a numericmetrics_at_action, on a platform whose campaign
metrics were collected on or after the due date) with the same
deterministicevaluate_outcomean agent would call, and appends the
closure record itself — one locked write for the whole pass, the writing
session'ssession_idand a machine-built (and scrubbed)reasonon each
entry, the whole scored snapshot inmetrics_at_actionso the verdict
stays checkable, nobatch_id, andlast_synced_atuntouched. The
response carriesauto_evaluations(what was closed, with the verdict) and
auto_evaluation_skipped(what still needs a manual evaluation, each with
a reason from a fixed vocabulary:external_origin,no_baseline,
no_campaign_id,platform_not_in_state,campaign_not_in_state,
no_current_metrics,unparseable_due_date,current_metrics_undated,
current_metrics_predate_window,period_mismatch,
no_comparable_metric). Metrics that predate the window are never scored,
a baseline and a snapshot that state differentperiodwindows are never
compared, and there is no fallback toplatform.last_synced_atfor an
undated snapshot. The write faces the same
policy gates amureo_state_action_log_appendcall does, so a read-only
deployment reportswrite_denied: <the gate's reason>and STATE.json is
left byte-identical; nothing is written when nothing is due either. Pass
auto_evaluate: falseto switch the pass off (both keys are then omitted),
and a failure degrades to anauto_evaluation_errorfield rather than
failing the read. -
Trimmed daily days are archived, not dropped (#758, phase 4). STATE.json
keeps the most recent 35 days ofplatforms[<p>].dailyand drops the rest on
every write, because the whole document is read and re-rendered on each
mutation. The days that trim removes now land in
history/daily/<YYYY-MM>.jsonbeside STATE.json first — one file per
calendar month with every platform in it, so aplugin:<dist>:<provider>
key never has to become a filename — written on the same state lock, before
the trimming write. A failing archive fails the write and leaves STATE.json
exactly as it was; a month file that does not parse is refused rather than
overwritten, since rewriting it would delete a month of history to tidy a
parse error. The document, the dashboard and every existing reader are
unchanged. Downstream writers that mergedailyinside their own document
write (with_platform_daily, #710) still drop what they trim unless they
pass the newarchive=callback — see
mureo.context.history.archive_platform_daily. -
Every version of every report is kept (#758, phase 4).
mureo_state_report_setoverwritesreports[<kind>], so only the latest
version of a verdict survived in STATE.json and nothing could show how it
moved. Each write now also appends one line to
history/reports/<kind>.jsonl— the version being written included, so the
ledger alone is the full series — carryingrecorded_at, the writing
session'ssession_id(which joins it toJOURNAL.jsonland to
action_logentries of the same session) and the MCPclientwhen one is
known. STATE.json still holds exactly the latest version and the dashboard
is untouched. Both archives are owner-only (0600); a report kind that
could escape its directory is refused before any file is touched.
history/lives in the workspace besideSTATE.jsonandJOURNAL.jsonl,
so the record travels with the workspace exactly as the journal does — and,
like the journal today, it has no rotation or size bound yet; that
arrives with #758 phase 6. -
Rationale and actor on every action_log entry (#758, phase 2). Every
mutating MCP tool — built-in and plugin — now takes an optionalreason
string: one or two sentences naming the evidence and the expected effect.
It is stripped out of the arguments before the policy gates run, so a
denied or refused call records why it was attempted; it goes into the
journal under its ownrationalekey (besideargs, never inside it),
and is stamped onto whateveraction_logentry the call produces. The
rationale is scrubbed by the same rules as the rest of the trail, at one
boundary, soSTATE.jsonandJOURNAL.jsonlcannot redact differently.
The entry
gains three fields:reason,session_id— the journal session of the
process that wrote it, which is what joins a curatedaction_logline to
the completeJOURNAL.jsonlrecord of the same session — andclient, the
MCP client label,nullfor a CLI or library write. All three are stamped
at the single write choke point
(mureo.context.state.append_action_log), so the five recording paths —
themureo_state_action_log_appendhandler, the native status-toggle
recorder, the plugin/bridge promotion, the rollback executor and the change
importer — pick them up without a line of their own. An over-longreason
is refused rather than truncated (500 characters), the same write-boundary
rule the dashboard display fields follow. Three tools that write
STATE.jsonrather than an ad account takereasontoo —
mureo_batch_begin,mureo_state_set_conversion_eventsand
mureo_external_changes_import— even though they are not classified as
mutations. Three do not:mureo_state_action_log_append, which records a
change instead of making one and so carries the rationale in its
entry.reasonfield, and the two that already declared areasonof
their own —mureo_state_platform_not_collected_setand
mureo_state_workspace_not_collected_set, where it is the persisted "why
was this not collected" note — which are left completely alone: no
injection, no stripping, no rationale. An observed entry
(origin: "external") never inherits the importing call's rationale:
mureo did not make that change and has no standing to say why it was made.
Old STATE.json files gain no new key: the three
fields are emitted only when set, so an entry written before this round
trips byte-identically.mureo journal's last column now shows the failure
reason for a non-okcall and the rationale for a successful one (header:
reason/rationale);--jsoncarries both in full. -
Decision records (#758, phase 3). A new append-only
decisionssection
inSTATE.jsonand one tool,mureo_decision_record, so a proposal, the
operator's answer to it, and the figures it was judged on survive the
session that held them.action_logsays what changed; it cannot say what
was CONSIDERED — a proposal that was turned down changed nothing, so it is
correctly absent from it, and it is exactly the record that stops the next
session proposing the same thing again. A record carriesstatus
(proposed/adopted/rejected/deferred),title(≤120),
rationale(≤2000), themetricsit was judged on (≤20 keys, each value a
string / number / boolean / null — a nested object is refused, so the
figures stay comparable to anaction_logentry'smetrics_at_action),
and the joins back to the rest of the document:related_actions(indices
into the append-onlyaction_log, validated at write time),batch_id
(validated against the declared batches — a CLOSED batch is accepted here,
unlike on anaction_logappend, because the verdict on a bulk pass is
normally recorded after it finished) andsupersedes.decision_idand
recorded_atare minted server-side (the #460 rule), andsession_id/
clientare stamped from the writing session exactly as on anaction_log
entry, so a decision and the changes it produced join on the same id.
The section is append-only: adopting a proposal appends anadopted
record naming the first insupersedesrather than editing it, because
editing in place would destroy the one thing the trail is for — that the
decision was first made as a proposal, at a time, on figures that were true
then. This is notdisplay.proposals: that section is the dashboard's
screen and is REPLACED WHOLE on everymureo_state_display_set, so a
proposal written on Monday is gone the moment Tuesday's skill draws the
screen — it cannot be a history. Every bound is refused, never
truncated, the same write-boundary rule the display fields follow. Old
STATE.jsonfiles gain no new key: the section is emitted only when it
holds something, so a document written before this round-trips
byte-identically.title,rationaleand every stringmetricsvalue are
scrubbed for secret-shaped substrings on the way in — the same
scrub_textboundary anaction_logreason crosses, so the two trails
cannot disagree about what a secret is — and C0/C1 control characters are
stripped from them (TAB and newline survive: a rationale may be a short
paragraph).mureo_state_getreturns the section when present and gains a
decisionsargument that scopes it —all(default) ornone, with
decisions_totaland adecisions_scopemarker left behind so an omitted
trail is never read as an empty one, and independent ofaction_logso
dropping the log to save context does not silently drop the reasoning
behind it. The tool is deliberately not classified as a platform
mutation — it writesSTATE.json, not an ad account — so it triggers no
strategy reminder and takes no injected call-levelreason: it carries its
ownrationale. No dashboard change in this phase: rendering the trail
is a follow-up.
Fixed
- Socket reads on the configure and wizard servers are bounded (#773). The
configure UI'sConfigureHandlerand the browser OAuth wizard's
_WizardHandlerset no socket timeout, so a POST that announced a
within-capContent-Lengthand then never sent the body left
rfile.read(length)blocked forever and pinned the worker thread serving
it. Both handlers now settimeout = SOCKET_READ_TIMEOUT_SECONDS(30 s),
which the stdlib applies to the connection insetup(); an expired read is
caught, logged through the handlers' own logger, and closes just that
connection. The deadline covers socket reads only — the request line,
headers and body — and does not bound the time a handler spends calling a
platform API after the request has been read. Both servers are loopback-only
and no reachable code path could exploit this remotely, but #766 had widened
the stalled-body window to Host-rejected requests too.
Changed
- Journal
RECORD_VERSIONis2(#758, phase 6): a record now ends with
the two chain keysprevandh. Every reader —mureo journal,
mureo.mcp.journal_readandmureo_history_query— accepts both versions,
and av: 1line written before the chain existed is reported as
unchainedby--verifyrather than as a break. No migration: an existing
journal keeps its lines exactly as they are and the chain starts at the next
one written.