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.
| noun | what it is |
|---|---|
| rule | a governance text (golden rules, lab rules, vocabulary); rows, so one rule can be added, changed or removed alone |
| state section | one section of the present truth; written by the service from the deltas agents post |
| log | one short entry per unit of work (≤ 600 words), append-only, filed in arrival order |
| debt | a row: area, debt, impact, status, opened, closed, resolution, next action |
| document | a report or a note, written once and readable back |
| archive | a 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
- One writer, one store: a second instance refuses with the lock’s path.
- Agents post deltas for the state, never the document.
- The log has no PUT and no DELETE: a correction is a new entry.
- A close needs a resolution and a date; a closed debt is not reopened by a status change.
- A log write never waits for compaction.
- The service is correct without a model: the digest always exists.
- 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:
| finding | fix |
|---|---|
/skill served POST %!s(MISSING)/api/v1/document | eight 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-only | GET /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 text | each line carries the debt, cut to a sentence |
| no discovery surface | GET /api/v1 is the index of the 33 verbs; a typo is still a 404 |
| an empty account still closed the cycle | under 40 words the answer is refused and the digest stays |
/log was not idempotent | client_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:
| finding | fix |
|---|---|
GET /debts?status=nonsense answered 200 with zero | the status is validated; an unknown one is a 400 naming the four |
rejectUnknown covered /log, /archives, /documents only | also /debts (status, q) and /context (budget_bytes, since) |
a write on a -read-only instance answered 500 | 503 with a sentence, before any query |
| the contract said the date was required; the code defaults to today; the “three words” rule was undocumented | the text and the index say what the code does |
a reused client_entry_id with other words dropped the new text in silence | conflict: true: the first entry stays and the caller is told |
| a restore lost the closed cycles’ log rows | each 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 closed | the 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
| finding | fix |
|---|---|
| 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 nothing | refused with the field it wants; the five ops’ fields are in the contract |
budget_bytes looked like a ceiling; the rules are always included | the contract says the rules are a floor |
POST /render recreated the retired root files inside the tracked skill directory | render writes the backup tree only: one export location |
/archives without a limit was 362 KB; /debts returned 110 rows | the list carries no bodies (4.7 KB default), the register pages with a cursor |
the backup’s status check looked only at memory/backup | it 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.
| finding | fix |
|---|---|
| the export banner was imported as the state preamble, replacing the real text | the 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 cycles | each cycle carries meta.json (created_at, span, words), and the importer reads it |
/search’s limit bounded each source, not the answer | the limit bounds the answer |
the bundle dropped the log without naming it, and returned null lists | it names the cut, and the lists are [] |
| the bundle showed the 20 oldest debts | newest first |
a -read-only instance seeded appointments at boot and logged a write error | a reader keeps no appointments |
budget_bytes=abc was silently replaced by the default | 400 |
| the served skill differed from the file by one byte | not 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
| finding | fix |
|---|---|
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 them | the 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 page | every 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
| finding | fix |
|---|---|
/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 parameter | cursor is accepted as the alias of since, as /log already did |
a restore renumbered the cycles: /archives/39 pointed at another cycle | meta.json carries the cycle’s id and the importer inserts it back |
| the index declared fewer parameters than the verbs read | the 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.