ambrogio-memory — the contract

Parent: entities/ambrogio-memory. Details: details/ambrogio-memory-verbs-detail.

Why it exists

The memory was four markdown files (state, chronicle, debts, past summary) edited by hand by several agents, with no version and no lock: two agents writing at the same moment overwrote each other silently, and the archive was three separate acts with no transaction. The service replaces that work, not the skill: the skill stays as the client contract.

The model

Six nouns. That is the whole schema.

nounwhat it is
rulea governance text (golden rules, lab rules, vocabulary); rows, so one rule can be added, changed or removed alone
state sectionone section of the present truth; written by the service from the deltas agents post
logone short entry per unit of work (≤ 600 words), append-only, filed in arrival order
debta row: area, debt, impact, status, opened, closed, resolution, next action
documenta report or a note, written once and readable back
archivea closed cycle: four snapshots inside the database, a code-written digest, and the prose the internal agent writes

There is no client → file edge anywhere: an agent posts, the service writes, and the only reader of the database is the service.

The boot bundle

GET /api/v1/context answers, in this order and inside a byte budget (32 KiB by default): the rules, the open debts (each with its own text, capped at 20), the state as a digest (title and one line per section), the last cycle’s account, and the recent log titles with a cursor. It names what it left out (omitted, truncated, rules_over_budget): a bundle that hides its own cut is a bundle that lies.

Compaction: code, asynchronous, immediate

A log write counts the open cycle’s words in the same transaction. When the count crosses chronicle_gate_words (3,000 by default) the write queues a job and returns at once — no agent waits. A goroutine claims the job and, in one transaction, snapshots the four documents, marks the entries as belonging to the cycle, writes a digest (counts, movement, the entries that carried the most) and resets the counter. Readers see the store before or after, never half a cycle.

The internal agent

The prose of a cycle is written by an agent inside the process, called by the same worker right after the act: it builds the prompt from the digest and the entries, calls deepseek-v4.1-flash on https://opencode.ai/zen/go/v1, validates the answer (not empty, at least 40 words, cut at 400) and writes summary_md in one transaction. The key is read once at boot from a 0600 file (gopass infra/ambrogio-memory/opencode-key is the source). Without the key the service runs and the digest stays.

Two measured facts are worth keeping: the provider answers 429 endpoint is unavailable to a named session id (a random hexadecimal one is required), and the cage must grant the resolver’s real directory, because /etc/resolv.conf is a symlink and Landlock judges the target — without it the failure reads as dial udp [::1]:53: connection refused.

Backup and restore

Every 30 minutes (state-backup) the service renders the whole memory as markdown into SKILLS/memory/backup/, commits only that path and memory/SKILL.md, and pushes with a token in a 0600 file. Git stores the changed lines, so the repository grows by diffs.

On another machine: clone SKILLS, start the service, and

ambrogio-memory -restore-from SKILLS/memory/backup

The tree is the same shape the importer reads. A restore keeps the state, the debts, the documents, all the closed cycles and the rules; the open cycle’s entries come back as log rows, the closed ones as archives with their snapshots.

Invariants

  1. One writer, one store: a second instance refuses with the lock’s path.
  2. Agents post deltas for the state, never the document.
  3. The log has no PUT and no DELETE: a correction is a new entry.
  4. A close needs a resolution and a date; a closed debt is not reopened by a status change.
  5. A log write never waits for compaction.
  6. The service is correct without a model: the digest always exists.
  7. The service never calls git with anything but the backup path, and never force-pushes.

The independent test of 2026-10-21

A fresh agent, with no memory of the build, tested the service and found seven defects. All seven are closed in 66ef795:

findingfix
/skill served POST %!s(MISSING)/api/v1/documenteight placeholders, eight arguments; two tests refuse a %! or an unfilled %s
/archives ignored limit/since; unknown parameters were ignored (?after=35 returned everything)pagination with a cursor, and a 400 that names the parameters the verb accepts
documents were write-onlyGET /documents and /documents/{slug}; the export carries the kind, so a restore keeps a note a note
the boot bundle’s debts had no debt texteach line carries the debt, cut to a sentence
no discovery surfaceGET /api/v1 is the index of the 33 verbs; a typo is still a 404
an empty account still closed the cycleunder 40 words the answer is refused and the digest stays
/log was not idempotentclient_entry_id: a retry returns the entry it already wrote

The lesson worth keeping is the class, not the list: a read that ignores a parameter is a read storm, and an append-only log without a client id is a duplicate on every timeout.

The second round, the same day

The same agent retested 11373b3 and found two real defects and four minors. Closed:

findingfix
GET /debts?status=nonsense answered 200 with zerothe status is validated; an unknown one is a 400 naming the four
rejectUnknown covered /log, /archives, /documents onlyalso /debts (status, q) and /context (budget_bytes, since)
a write on a -read-only instance answered 500503 with a sentence, before any query
the contract said the date was required; the code defaults to today; the “three words” rule was undocumentedthe text and the index say what the code does
a reused client_entry_id with other words dropped the new text in silenceconflict: true: the first entry stays and the caller is told
a restore lost the closed cycles’ log rowseach archive’s chronicle is imported as log rows that belong to it (a restore now returns ~480 rows, more than the production store, because the history lived only in the snapshots)
an account that is a placeholder of repeated words still closedthe summariser refuses an answer whose distinct-word share is under one half; the digest stays

The design choice left standing: the cycle is not marked degraded. An account that is too short or too repetitive is refused and the digest stays, which is a state the reader already knows how to interpret.

The third round, the same day

findingfix
the fourth snapshot of every new cycle was empty (the account lived only on the row)the snapshot carries the digest at the close, and the door falls back to the row’s account — a reader never gets nothing
a rename sent with to instead of title was counted as applied and changed nothingrefused with the field it wants; the five ops’ fields are in the contract
budget_bytes looked like a ceiling; the rules are always includedthe contract says the rules are a floor
POST /render recreated the retired root files inside the tracked skill directoryrender writes the backup tree only: one export location
/archives without a limit was 362 KB; /debts returned 110 rowsthe list carries no bodies (4.7 KB default), the register pages with a cursor
the backup’s status check looked only at memory/backupit watches SKILL.md too, so a contract change is committed

The fourth round: the safety net, tested by a fresh agent

A subagent with no memory of the build restored the shipped backup and rendered it again. The findings were all in the part nobody had measured.

findingfix
the export banner was imported as the state preamble, replacing the real textthe banner is not a section
every document lost its trailing newline on restore (the whole file was TrimSpace’d)the body is split untrimmed
verify compared the flat tree with the memory root and could never return zero (~480 divergences)it compares the data export (backup/)
a restore zeroed each cycle’s span and words and reordered a day’s cycleseach cycle carries meta.json (created_at, span, words), and the importer reads it
/search’s limit bounded each source, not the answerthe limit bounds the answer
the bundle dropped the log without naming it, and returned null listsit names the cut, and the lists are []
the bundle showed the 20 oldest debtsnewest first
a -read-only instance seeded appointments at boot and logged a write errora reader keeps no appointments
budget_bytes=abc was silently replaced by the default400
the served skill differed from the file by one bytenot a defect: jq -r appends a newline; compare with jq -j

The proof that closes the round: restore the shipped backup, render again, and diff -r the two trees — zero differences. The one accounting note: posting the report itself adds a log delta, so production’s counters read reports +1 and logs +1, not identical.

The fifth round

findingfix
eleven debts carried the status “in progress” (a space, from the old markdown register): no ?status= value could reach them, while the open counter counted themthe import normalises the space to _, and a one-time UPDATE brought the stores level — TD-003 … TD-133 are in_progress
a list’s count was read as “how many exist” and was the pageevery list carries total (the filter’s size) beside count (the page), and the contract says the lists are pages

open_debts reads 122 = 110 open + 11 in_progress + 1 deferred, which is the register as it is. The tester could not exercise the repetitive-account refusal without seeding a noisy entry; that path is covered by summarise’s unit test and not by a live run.

The sixth round

findingfix
/search’s limit bounded each of the four sources, not the answer (limit=2 → 7 hits)the sources are interleaved and the answer is cut at the limit
/archives and /documents answered with a cursor they refused as a parametercursor is accepted as the alias of since, as /log already did
a restore renumbered the cycles: /archives/39 pointed at another cyclemeta.json carries the cycle’s id and the importer inserts it back
the index declared fewer parameters than the verbs readthe index lists them all
verify failed on the first boot of a new memory directory (lstat … no such file)a tree that is not there yet is not drift

The round trip stayed at zero differences after the change, and /archives/{id} now addresses the same cycle before and after a restore.

See Also