Sessions imported by `backfill` before it carried per-event timestamps all have started_at/ended_at on the import day, which flattens every time-ordered view of that history. This adds an admin repair for sessions already imported; it is independent of the companion fix to `backfill` itself (#919). The repair runs online, through the server's single writer, because it does not need direct disk access (invariant 9). POST /admin/repair-session-times validates a caller-supplied batch of (session_id, started_at, ended_at) candidates in one transaction, scoped to (workspace, project) like purge-session. It only rewrites a row whose current started_at postdates the candidate's own transcript end, the bug's signature, so a correctly hook-captured session or a re-run is a no-op (`not_flattened` / `unchanged`). Without confirm it is a real dry run: the write runs and rolls back. Out-of-scope sessions are reported not-found and left untouched; negative, inverted or future times are refused; an open session never gets an end time; and a confirmed batch writes one audit_log row with every repaired session's before/after times. `ai-memory repair-backfill-timestamps` is the thin client. It reuses backfill's session discovery and the workstream transcript reader, takes each session's earliest and latest event time, resolves native ids with the new `SessionId::from_native` (now shared with the hook router instead of a private copy), chunks the batch at the server's cap, and prints each repaired session's old and new times.
25 KiB
Admission webhooks: pre-persistence HTTP hooks
Operator-configured HTTP hooks invoked on the engine's write path (
Wiki::write_page,delete_page,purge_project,purge_workspace,move_project, and handoff lifecycle operations) just before the durable mutation commits. Write hooks can mutate the page (return a new frontmatter / body); delete/purge/move hooks are notifications that can observe, mirror, or reject. Sourced fromcrates/ai-memory-wiki/src/admission.rsand the wiring incrates/ai-memory-cli/src/commands/serve.rs— keep both as the canonical reference if anything here drifts.
1. What this is (and isn't)
| What you can do | What you can't do | |
|---|---|---|
| Chain | Add canonical frontmatter fields (e.g. contributors); mirror the write into an external system (git, search index, audit log); reject writes that fail policy (e.g. validate-no-secrets). |
Talk back to the engine's writer or store directly — the chain only sees one page at a time and mutates it in place. |
| Engine | Stays closed for modification: every new behaviour ships as an independent HTTP service in any language. | The engine doesn't discover or auto-register webhooks — operators name them in config. |
If your idea doesn't fit "mutate the page or observe the write", it's
probably a different extension point (/hook ingress, /admin/* admin
surface, or out-of-band scheduled job).
2. Lifecycle
The blocking chain fires inside Wiki::write_page, after the markdown is
parsed and initially sanitised but before the atomic write and store upsert.
Webhook mutations are sanitised again before persistence. A mutation applied
by a webhook propagates to both the on-disk markdown file and the SQLite row
in a single atomic step (see
ARCHITECTURE.md on the writer actor / atomic write
invariants).
Webhooks run sequentially, in the order declared in config. Each one
sees the (possibly mutated) page from the previous webhook — so a
contributors hook that adds frontmatter is followed by a git-mirror
hook that mirrors the enriched page.
Webhooks fire on these op values today (extensible enum):
write_page— direct writes via MCPmemory_write_page, the CLIwrite-page,/admin/write-page, the lint rewriter, hook synthesis.consolidate— LLM consolidation writes from the consolidator (SessionEnd opt-in + PreCompact + manualmemory_consolidate) and the rule-based PreCompact/PostCompaction fallback after a provider failure. Fires up to twice per consolidation: once as an admission preflight before the LLM call — with an empty body and the target page path (for multi-page runs, the canonicalsessions/<id>.mdanchor path) — and again as the normal write-time check with the real content. Treat blocking calls as decisions, not delivery events: gate onop/actor/workspace/project/ path, don't reject solely because the body is empty, and don't count blocking calls as one-write-happened side effects (use a non-blocking observer webhook for that). Any mutation returned during the preflight is discarded.delete— a single page is removed (Wiki::delete_page, triggered by thememory_delete_pageMCP tool). Carries the page path, no body; fired before the file is removed so a mirror cangit rmthe same path. The SQLite index is deleted directly by the writer actor; the watcher does not reconcile delete events.purge_project— a whole project is purged (Wiki::purge_project→remove_dir_all, routed from/admin/purge-project). Carries the project inctx, no page path; fired before the directory is removed so a mirror can drop the project.purge_session— one session is purged (routed from/admin/purge-session). Carries the workspace/project inctx, no page path — the operation is driven by session id rather than by path. Fired before any row is deleted, so areject-policy webhook can refuse the purge while the session's data is still intact. Non-blocking observers are dispatched again after the SQL purge and page-file cleanup are durable.purge_workspace— a whole workspace is purged (routed from/admin/delete-workspace). Carries the workspace inctx.workspace, leavesctx.projectempty, and has no page path; fired before SQL/file destruction for reject-policy admission and dispatched again to non-blocking observers after durable work.move_project— a whole project is moved between workspaces without changingproject_id(fresh-destination true move). Carries the source project inctx.workspace/ctx.project, destination names inctx.destination_workspace/ctx.destination_project, and no page path; fired before the directory rename + DB re-stamp so a mirror can rename or reject the project move.move_session— one session (its rows and itssessions/<id>.mdpage) is moved to another project (/admin/move-session). Carries the source project inctx.workspace/ctx.project, destination names inctx.destination_workspace/ctx.destination_project, and no page path; fired before the page file moves and the DB re-stamps so a mirror can move or reject it.handoff_begin/handoff_accept/handoff_cancel— a handoff is created, consumed, or discarded. Carries the workspace / project and the acting operator, no page path (handoffs live in their own table, not the wiki tree). Fired by the MCP tools and by the automatic hook paths: the SessionEnd baton (handoff_begin) and the session-start claim (handoff_accept).
The three handoff lifecycle ops are dispatched in one fixed order, the same
from every path that raises them: the webhooks that can refuse — blocking
with failure_policy = "reject" — are awaited before the operation, the
operation then runs, and every other subscriber (observers, blocking or not) is
dispatched fire-and-forget after it, only if it happened. So no observer is
told about an accept that found no pending handoff or a cancel another
operator's ownership refused — an accept with nothing pending is not
announced to a decider either, since the engine knows there is nothing to
accept before it asks — and an observer subscribed to one of these ops sees the
same event whether it came from an MCP tool or the hook ingress. (write_page
and the notification ops below are unchanged: there, a blocking webhook is
awaited up front whatever its failure_policy — on write_page because it can
still mutate the page.)
What a reject costs the caller depends on which path raised the op, and
the difference is deliberate. On the MCP tools — memory_handoff_begin,
memory_handoff_accept, memory_handoff_cancel — a refusal aborts the tool
call and is returned to the caller as a JSON-RPC error, exactly like
write_page: there is a caller who asked for the operation, so it is told the
operation did not happen. On the automatic paths there is no such caller, so a
refusal degrades the lifecycle event instead of failing it:
- SessionEnd asks before
end_sessioncommits; a refusal skips the baton, is logged, and the session page / opt-in consolidation / auto-commit still run. The session ends without a handoff rather than ending with no summary at all. - The session-start claim is served by the synchronous hook path. The shortest shipped caller is the shell hook's one-second curl deadline (native hook commands allow three seconds), so the server caps admission at 750 ms. A refusal, that server deadline, a per-webhook timeout or an unreachable host leaves the handoff open for the next session; it never fails the session start or consumes context after the caller has disconnected.
Budgeting the session-start claim: the deciding webhooks are awaited
sequentially, and the chain stops at the first refusal, so what the operator
waits for in the worst case is the sum of the timeout_ms of every
reject-policy webhook subscribed to handoff_accept — not the largest of them.
That sum is then capped by the server's 750 ms automatic-claim deadline.
timeout_ms still defaults to 2000 ms per webhook for ordinary MCP and write
paths; on automatic session-start acceptance, a chain that cannot approve
within 750 ms is treated as a refusal and the baton remains open. Configure
smaller per-webhook values when you need logs to identify which decider timed
out rather than the aggregate deadline firing first.
delete / purge_project / purge_workspace / move_project / move_session are notifications — there is no
body to mutate; a Reject-policy webhook still aborts the operation
(admission fires BEFORE the SQL destruction in both the /admin/purge-project
and /admin/delete-workspace paths, and before source teardown in
/admin/move-project copy-purge paths, so reject leaves the source intact).
Each webhook opts into the ops it cares about via events; the chain checks the
op against WebhookConfig::events before dispatching.
Forget-sweep decay eviction uses the same conditional delete notification
before removing the Markdown file and writing its tombstone. Aged cleanup
never removes a file: if Markdown has reappeared at the path, it is preserved
and reindexed before only the old SQLite version chain is removed. There is no
file-mirror event for that DB-only history purge.
A copy-purge /admin/move-project fires two webhook events from
one request: one or more write_page notifications as the pages copy
into the destination, then one terminal purge_project notification
when the source is torn down. The purge_project event carries
partial_failure: true if the SQL purge committed but the on-disk
dir removal failed afterwards.
/admin/purge-session emits purge_session. Its final async observer
notification carries partial_failure: true if the SQL purge committed but
the session page file could not be removed afterwards.
/admin/delete-workspace emits purge_workspace. Its final async observer
notification carries partial_failure: true if the SQL workspace cascade
committed but <wiki_root>/<workspace_id> could not be removed.
During the copy leg, the engine skips only the webhook named exactly
contributors. Move copies preserve page frontmatter verbatim, including any
existing contributors list, so re-running that enrichment hook for every page
adds bulk-move latency without changing the copied page. Other write_page
webhooks, such as a git-mirror, still run normally for each copied page; the
terminal purge_project notification is unchanged.
What does NOT fire the chain (by design)
log.md/log-YYYY-MM.mdappends — written on every hook event (per prompt/tool-call). Routing each through the chain would mean an HTTP POST per observation, violating the fire-and-forget hook budget. The per-event log is a local audit artifact; back it up out-of-band (batched rsync), not per-line.- The page bodies behind a handoff — the
handoff_*ops above carry only the scope and the acting operator. A handoff is SQLite rows, so a file mirror has no file change to reconcile from one; what it gets is the lifecycle event, not a page. - Aged decay-history cleanup — only the old SQLite version chain changes;
any Markdown file at the path remains authoritative and is reindexed first
when necessary. Initial decay eviction and frontmatter TTL expiry use the
conditional
deleteadmission path. rename-project— aprojects.namecolumn update; the on-disk path is the stable UUID, so no file moves and nothing to propagate.rename-workspace— aworkspaces.namecolumn update plus refreshed_meta.mdmanifests; workspace paths are stable UUIDs, so no file move notification is needed.repair-backfill-timestamps(/admin/repair-session-times) — asessions.started_at/ended_atcolumn update only; no wiki page or file is touched, so there is nothing for a mirror to reconcile.- External / manual edits on disk — reconciled by the watcher, not the admission chain (the chain is for the engine's own write path).
3. Wire contract
Request (engine → webhook)
POST <webhook.url>
Content-Type: application/json
X-Memory-Op: write_page | consolidate | delete | purge_project | purge_workspace | move_project
| move_session | handoff_begin | handoff_accept | handoff_cancel
(The header is one of those ten values; the second line is a continuation of the list, not a second header.)
{
"page": {
"path": "gotchas/example.md", // relative wiki path (PagePath)
"frontmatter": { "title": "...", ... }, // arbitrary JSON, may be null
"body": "..." // markdown body, no frontmatter block
},
"ctx": {
"workspace": "default", // resolved name (see §5)
"project": "ai-memory-ops", // resolved name
"destination_workspace": "archive", // move_project / move_session only; omitted otherwise
"destination_project": "ai-memory-ops", // move_project / move_session only; omitted otherwise
"actor": { // request-layer identity
"agent": "claude-code", // claude-code | codex | opencode | hook | cli | …
"user": "djalmajr", // null when unauthenticated
"sub": "8f3a-...", // JWT sub
"client": "72836f52-...", // DCR client UUID
"session_id": "019e6d-..."
},
"op": "write_page", // write_page | consolidate | delete | purge_project | purge_session | purge_workspace | move_project | move_session
"partial_failure": true // purge_project/purge_session/purge_workspace only, and ONLY when set
// (skipped on the wire when false).
// true → the DB rows were purged but
// filesystem cleanup failed afterwards;
// a filesystem-tracking mirror (git push)
// should refuse to drop its own copy.
}
}
The WebhookRequestBody / WebhookPagePayload / ActorContext /
AdmissionContext types in
crates/ai-memory-wiki/src/admission.rs are the authoritative
serialisation source.
Response (webhook → engine)
| Status | Body | Behaviour |
|---|---|---|
200 OK |
{ "page": { "frontmatter": ..., "body": ... } } — both inner fields optional. Anything missing means "leave that field unchanged". |
The engine swaps in the returned values before the next webhook (or the final atomic write). |
204 No Content |
(empty) | The engine treats the webhook as a pure observer / side-effect — no mutation, no parse. |
4xx / 5xx |
(optional textual body, logged) | See §4 Failure policy. |
The engine bounds the response read at MAX_RESPONSE_BYTES (1 MiB).
Anything beyond that is treated as a no-op with a warn log — webhooks
have no legitimate reason to return more than the page envelope.
4. Failure policy
Each webhook picks one when the engine can't reach it or it returns non-2xx:
ignore(default, recommended) — Engine logs awarnand continues with the unmutated page. The page write still succeeds. This is the right choice for everything except safety-critical enforcers.reject— Engine aborts the write, propagating the error up to the caller. Use this only when the webhook is a hard precondition for persistence (e.g. a futurevalidate-no-secretsenforcer).
A webhook that subscribes to multiple ops uses the same policy across all of them — including the handoff lifecycle ops above, where "abort" means "decline this handoff", not "fail the event".
5. Workspace / project names
The engine resolves workspace_id and project_id into the same
human-readable names the UI and on-disk wiki use, so webhooks can
address pages by name without re-implementing UUID lookup. Resolution
happens just before the chain fires; both fields are empty when the
wiki was built without [Wiki::with_store_reader] (e.g. legacy
embedders, tests that wire a chain without a reader).
External webhooks should treat the names as opaque strings (workspace /
project values use the same validation as --workspace / --project
CLI flags). They are stable for the lifetime of the workspace / project
— the engine doesn't rename them silently. rename-project is a manual
op that an operator runs and that will eventually trigger a fresh
webhook fan-out if you wire one.
6. Loop prevention
A webhook that turns around and writes back to the engine (e.g. via
/admin/write-page, memory_write_page, or the hook ingress) must include the
header
X-Memory-Skip-Admission-Chain: <name>[,<name>...]
on its re-entrant call. The engine matches the CSV against
WebhookConfig::name and short-circuits those hooks for that write, but only
for trusted re-entry (root/auth-disabled requests). Regular DB-user writes
cannot set this header to bypass a reject-policy webhook. Without the skip
header on trusted re-entry, you get infinite recursion (engine → webhook →
engine → webhook → ...). The header propagates only for the single re-entrant
write; the next external write picks the chain back up normally.
7. Limits
Constants are exported from the ai-memory-wiki crate root:
| Constant | Value | What it caps |
|---|---|---|
MAX_ADMISSION_WEBHOOKS |
16 |
Chain length. AdmissionChain::new errors out beyond this — a misconfigured template (helm loop, duplicated block) can't push N hooks into the write-path. |
MAX_RESPONSE_BYTES |
1 MiB |
Webhook response body. Beyond this the response is dropped (treated as no-op + warn). |
Per-webhook timeout_ms |
operator-set (default 2000) |
Single request. The chain is sequential, so total worst case ≈ Σ timeout_ms; automatic session-start handoff acceptance additionally caps the whole deciding chain at 750 ms. |
8. Configuration
config.toml:
[[admission_webhooks]]
name = "contributors" # stable identifier (used by skip list + logs)
url = "http://contributors.memory.svc.cluster.local:8080/enrich"
timeout_ms = 2000 # per request
failure_policy = "ignore" # ignore | reject
events = ["write_page", "consolidate"]
blocking = true # runs synchronously; may mutate / reject
[[admission_webhooks]]
name = "git-mirror"
url = "http://git-mirror.memory.svc.cluster.local:8080/sync"
timeout_ms = 2000
failure_policy = "ignore"
events = ["write_page", "consolidate", "delete", "purge_project", "move_project"]
blocking = false # fire-and-forget after the write; never blocks it
blocking (default true)
A webhook is either blocking or non-blocking:
blocking = true(default) — runs synchronously inside the write path. It can mutate the page (write_page/consolidate), and arejectfailure aborts the write. The write waits for it (up totimeout_ms). Use for enrichers/validators (e.g.contributors,validate-no-secrets).blocking = false— dispatched fire-and-forget after the durable operation has completed. For writes that means the final page is on disk and indexed in SQLite; for deletes that means the file and index row are gone; for project purges that means the DB purge has completed and filesystem removal has been attempted; for project moves it means the directory and DB rows now point at the destination workspace. The engine does not wait for it and ignores its response, so it cannot mutate or reject — it only observes/mirrors the final state. Use for pure backups/mirrors (e.g.git-mirror) so a slow or down sink never adds latency to writes. Still honourseventsand the skip list.
Since the blocking chain is sequential, total worst-case write latency is
Σ timeout_ms over the blocking webhooks only; non-blocking ones add none.
Fire-and-forget dispatch is bounded: 256 such requests may be in flight per
process, and beyond that the request is dropped and logged rather than queued
(the durable operation has already completed, so the caller is never stalled).
On the handoff lifecycle ops — handoff_begin, handoff_accept,
handoff_cancel — only a reject-policy webhook is awaited, so a
blocking = true hook with failure_policy = "ignore" is dispatched
fire-and-forget there and shares that budget: under sustained load it too can be
dropped. Such a drop is logged at ERROR (a non-blocking one at WARN). A hook that
must not be dropped on those ops needs failure_policy = "reject", which makes
it a decider — awaited before the operation, and able to refuse it.
Env override:
AI_MEMORY_ADMISSION_WEBHOOKS_JSON='[{"name":"contributors","url":"http://contributors.memory.svc.cluster.local:8080/enrich","timeout_ms":2000,"failure_policy":"ignore","events":["write_page","consolidate"],"blocking":true}]'
The JSON env var is canonical for webhook lists because the figment env layer
does not reliably reconstruct Vec<Struct> from indexed nested variables.
Empty config = no chain attached → zero overhead per write (no client built, no per-write branch).
9. Worked examples
Mutating: append the writer to frontmatter.contributors
// POST /enrich
{
"page": { "path": "gotchas/x.md", "frontmatter": { "title": "X" }, "body": "..." },
"ctx": { "workspace": "default", "project": "ai-memory-ops",
"actor": { "agent": "claude-code", "user": "djalmajr", "client": "72836f52-..." }, ... }
}
// → 200 OK
{
"page": {
"frontmatter": {
"title": "X",
"contributors": [
{ "agent": "claude-code", "user": "djalmajr", "client": "72836f52-...",
"first_seen": "...", "last_seen": "...", "writes": 1 }
]
}
}
}
The engine replaces frontmatter with the returned object before
persisting. body is left untouched (not in the response).
Side-effect: mirror the write into an external git repo
// POST /sync (same request body)
// → 204 No Content
The webhook materialises the page into a local clone of an external
repo, batches commits, and pushes asynchronously. The engine doesn't
wait for the push — only the local enqueue runs inside the write path
under the webhook's timeout_ms.
10. Tests
crates/ai-memory-wiki/tests/admission.rs covers the wire contract end
to end against an axum loopback server. Categories:
- Mutating frontmatter and body propagates correctly.
204is a no-op (frontmatter / body unchanged).failure_policy=ignoreswallows errors;failure_policy=rejectaborts.- Multi-webhook chain runs in declared order; each sees the previous mutation.
X-Memory-Skip-Admission-Chainshort-circuits named hooks.X-Memory-Opheader is set correctly per op.op-filtering: webhooks only fire on subscribed events.MAX_ADMISSION_WEBHOOKSrejection at construct time.MAX_RESPONSE_BYTEScap drops oversized responses.workspace/projectresolution propagates into the payload.
crates/ai-memory-wiki/src/wiki.rs::tests::write_page_resolves_workspace_and_project_names_for_chain
covers the integrated path (Wiki::write_page → store reader resolution
→ chain → recorded payload).
11. Where to look in source (the canonical spec)
| Concept | File:line |
|---|---|
AdmissionContext / ActorContext / wire structs |
crates/ai-memory-wiki/src/admission.rs |
AdmissionChain::run (the hot loop) |
crates/ai-memory-wiki/src/admission.rs |
Invocation inside write_page (resolution + chain call) |
crates/ai-memory-wiki/src/wiki.rs::Wiki::write_page |
Config schema ([[admission_webhooks]]) |
crates/ai-memory-cli/src/config.rs::Config::admission_webhooks |
Server wiring (with_admission_chain + with_store_reader) |
crates/ai-memory-cli/src/commands/serve.rs |
Header → ActorContext mapping (mcp-auth → engine) |
crates/ai-memory-mcp/src/actor.rs |
12. Non-goals (planned iterations, not blockers)
- Parallel fan-out. The chain is sequential by design today —
mutation composition is well-defined that way. A future
parallel = truefor side-effect-only webhooks (no body mutation expected) is possible but not in scope. - Webhook discovery / dynamic registration. Hooks are operator-named
in config. A future
/admin/admission-webhooksPOST surface is conceivable but explicitly out of scope here — single-tenant config is simpler and matches how the engine treats every other extension point ([[etl_sources]], etc.). - Per-webhook metrics surface. The chain logs via
tracingtoday. Surfacing per-webhook counters via/admin/statusis a natural follow-up but lives outside this contract.