Skip to content

v0.21.0

Choose a tag to compare

@hyoshi hyoshi released this 20 Sep 10:35
· 119 commits to main since this release
ba2c70a

Added

  • The journal is bounded, chained and checkable; a shortening
    action_log write leaves a copy
    (#758, phase 6). Three ways the audit
    trail could quietly stop being one, closed together. Rotation:
    JOURNAL.jsonl and each history/reports/<kind>.jsonl grew 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_BYTES to 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 the journal source 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
    with prev (SHA-256 of the previous physical line) and h (SHA-256 of the
    record without h), so an edited line fails its own hash and a removed or
    spliced one fails the next line's prev — 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 --verify walks 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 any unchained pre-phase-6
    records), and exits 0 / 1 / 2 (ok / broken / no journal); --json returns
    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. The action_log backup
    rule
    : action_log is 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_file now 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
    the history/ 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_log and the daily window only inside a whole
    mureo_state_get, the journal only through the mureo journal CLI, 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), the since / until UTC window, platform, campaign_id, the
    entity_type + entity_id pair, batch_id, and the journal-only tool,
    outcome, mutations_only and failures_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_log entries carry their
    index in the FULL log (the index related_actions and evaluation_of
    refer to); daily merges 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 as scanned_lines,
    because nothing rotates it yet, and a daily query with no since reads
    the archive back 12 months from until (or today) rather than opening every
    month a years-old account ever had — that section's window reports the
    dates it answered from and defaulted: true, and an explicit since
    reaches as far back as it is given. Read-only: the tool takes no call-level
    reason and 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 an observation_due stayed open until an agent remembered to
    append an evaluation_of record for it — mureo_outcome_evaluate is pure
    and writes nothing — so a forgotten one was re-evaluated on every run while
    the pending set grew without bound. mureo_state_get now scores every
    past-due entry whose outcome its own document determines (a campaign-level
    action with a numeric metrics_at_action, on a platform whose campaign
    metrics were collected on or after the due date) with the same
    deterministic evaluate_outcome an agent would call, and appends the
    closure record itself — one locked write for the whole pass, the writing
    session's session_id and a machine-built (and scrubbed) reason on each
    entry, the whole scored snapshot in metrics_at_action so the verdict
    stays checkable, no batch_id, and last_synced_at untouched. The
    response carries auto_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 different period windows are never
    compared, and there is no fallback to platform.last_synced_at for an
    undated snapshot. The write faces the same
    policy gates a mureo_state_action_log_append call does, so a read-only
    deployment reports write_denied: <the gate's reason> and STATE.json is
    left byte-identical; nothing is written when nothing is due either. Pass
    auto_evaluate: false to switch the pass off (both keys are then omitted),
    and a failure degrades to an auto_evaluation_error field rather than
    failing the read.

  • Trimmed daily days are archived, not dropped (#758, phase 4). STATE.json
    keeps the most recent 35 days of platforms[<p>].daily and 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>.json beside STATE.json first — one file per
    calendar month with every platform in it, so a plugin:<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 merge daily inside their own document
    write (with_platform_daily, #710) still drop what they trim unless they
    pass the new archive= callback — see
    mureo.context.history.archive_platform_daily.

  • Every version of every report is kept (#758, phase 4).
    mureo_state_report_set overwrites reports[<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 — carrying recorded_at, the writing
    session's session_id (which joins it to JOURNAL.jsonl and to
    action_log entries of the same session) and the MCP client when 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 beside STATE.json and JOURNAL.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 optional reason
    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 own rationale key (beside args, never inside it),
    and is stamped onto whatever action_log entry the call produces. The
    rationale is scrubbed by the same rules as the rest of the trail, at one
    boundary, so STATE.json and JOURNAL.jsonl cannot redact differently.
    The entry
    gains three fields: reason, session_id — the journal session of the
    process that wrote it, which is what joins a curated action_log line to
    the complete JOURNAL.jsonl record of the same session — and client, the
    MCP client label, null for 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 —
    the mureo_state_action_log_append handler, 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-long reason
    is refused rather than truncated (500 characters), the same write-boundary
    rule the dashboard display fields follow. Three tools that write
    STATE.json rather than an ad account take reason too —
    mureo_batch_begin, mureo_state_set_conversion_events and
    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.reason field, and the two that already declared a reason of
    their own — mureo_state_platform_not_collected_set and
    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-ok call and the rationale for a successful one (header:
    reason/rationale); --json carries both in full.

  • Decision records (#758, phase 3). A new append-only decisions section
    in STATE.json and 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_log says 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 carries status
    (proposed / adopted / rejected / deferred), title (≤120),
    rationale (≤2000), the metrics it was judged on (≤20 keys, each value a
    string / number / boolean / null — a nested object is refused, so the
    figures stay comparable to an action_log entry's metrics_at_action),
    and the joins back to the rest of the document: related_actions (indices
    into the append-only action_log, validated at write time), batch_id
    (validated against the declared batches — a CLOSED batch is accepted here,
    unlike on an action_log append, because the verdict on a bulk pass is
    normally recorded after it finished) and supersedes. decision_id and
    recorded_at are minted server-side (the #460 rule), and session_id /
    client are stamped from the writing session exactly as on an action_log
    entry, so a decision and the changes it produced join on the same id.
    The section is append-only: adopting a proposal appends an adopted
    record naming the first in supersedes rather 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 not display.proposals: that section is the dashboard's
    screen and is REPLACED WHOLE on every mureo_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.json files gain no new key: the section is emitted only when it
    holds something, so a document written before this round-trips
    byte-identically. title, rationale and every string metrics value are
    scrubbed for secret-shaped substrings on the way in — the same
    scrub_text boundary an action_log reason 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_get returns the section when present and gains a
    decisions argument that scopes it — all (default) or none, with
    decisions_total and a decisions_scope marker left behind so an omitted
    trail is never read as an empty one, and independent of action_log so
    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 writes STATE.json, not an ad account — so it triggers no
    strategy reminder and takes no injected call-level reason: it carries its
    own rationale. 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's ConfigureHandler and the browser OAuth wizard's
    _WizardHandler set no socket timeout, so a POST that announced a
    within-cap Content-Length and then never sent the body left
    rfile.read(length) blocked forever and pinned the worker thread serving
    it. Both handlers now set timeout = SOCKET_READ_TIMEOUT_SECONDS (30 s),
    which the stdlib applies to the connection in setup(); 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_VERSION is 2 (#758, phase 6): a record now ends with
    the two chain keys prev and h. Every reader — mureo journal,
    mureo.mcp.journal_read and mureo_history_query — accepts both versions,
    and a v: 1 line written before the chain existed is reported as
    unchained by --verify rather 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.