Team memory
Status: accepted (October 2026; decisions in §9). G1 (gateway auth), G2 (batch push), G3 (changes feed), C1 (astrocyte team), C2 (background sync, attribution, forget scopes) and C3 (share/unshare, doctor) and G4 (erasure, persisted legal holds) implemented.
A developer’s coding agents already remember a project locally: one SQLite file, per-project banks, automatic capture and recall. Team memory shares a project’s memory with the people working on it, through an Astrocyte gateway the team runs. A decision Alice’s agent saved on Monday is recalled by Bob’s agent on Tuesday, attributed to her. Nothing changes on the hot path: hooks and the MCP server still read and write only the local store.
For the local install see the end-user quick start; for access control, access-control.md; for multi-bank recall, multi-bank-orchestration.md; for the existing replication idea this replaces for the local case, memory-export-sink.md.
1. Goals and non-goals
Section titled “1. Goals and non-goals”Goals
- Share a project’s durable knowledge (decisions, conventions, imported docs) across a team’s agents: Claude Code, Codex, Antigravity, Copilot CLI.
- Recall stays local and fast (the prompt hook’s budget is 1.5 s; today it takes ~10 ms). It works offline; sync catches up later.
- Every shared memory says who shared it, and an erased memory is erased for everyone, from disk.
- Self-hosted on the existing gateway. No new service.
Non-goals (v1)
- Real-time collaboration. Minutes of lag are fine.
- Sharing conversations by default (see §2).
- Resolving contradictions between teammates’ memories. That is memory quality (recency, supersession), not sync.
- Cross-organization federation; a hosted offering.
2. What syncs
Section titled “2. What syncs”The unit of sharing is a project bank. A bank’s id is already derived from the normalised git origin URL (astrocyte/harness/project.py), so every teammate’s clone lands on the same project:<name>-<hash>. The team bank on the gateway uses the same id, so no mapping table is needed.
Within a joined project, what is shared depends on how the memory was made:
| Memory | Shared? | Why |
|---|---|---|
Saved by an agent (memory_retain) |
yes | A deliberate “remember this”: the case team memory exists for |
Imported (astrocyte memory import, metadata.import_hash) |
yes | The project’s own instructions and docs |
Captured turns (tag captured) |
no, opt-in per project | Raw conversation: half-formed ideas, personal asides, mistakes. Credentials are redacted, but the content is still a person’s working session |
Tagged private |
never | Per-memory override (astrocyte memory unshare <id>) |
astrocyte memory share <id> promotes a single captured turn. A project can opt in to sharing captured turns (team.share_captured: true), but it is never the default.
First sync is previewed. astrocyte team join ends with astrocyte team sync --dry-run: the count, and a sample, of what would go up. The user confirms before anything leaves the machine.
3. Topology: replicate, don’t proxy
Section titled “3. Topology: replicate, don’t proxy” Alice's laptop Gateway (team) Bob's laptop ┌──────────────────────┐ push ┌─────────────────────────┐ pull ┌──────────────────────┐ │ hooks / MCP │ ───────► │ bank project:api-1a2b3c │ ───────► │ hooks / MCP │ │ ↕ (local only) │ │ access control, PII, │ │ ↕ (local only) │ │ SQLite: own + mirror │ ◄─────── │ provenance, tombstones │ ◄─────── │ SQLite: own + mirror │ └──────────────────────┘ pull └─────────────────────────┘ push └──────────────────────┘ agent daemon syncs in the background (and `astrocyte team sync` on demand)Teammates’ memories are pulled into the local bank and recalled like any other memory: the same relevance gate, boot summary and resume. The agent daemon pushes and pulls every few minutes and on demand.
Rejected: live remote recall per prompt. The gateway already supports federated “proxy” recall (recall/proxy.py), but:
- it would put a network round trip on the prompt path and fail offline;
- it refuses private and loopback addresses, which is where self-hosted gateways live;
- it drops timestamps.
Replication costs disk (the team’s text, re-embedded locally) and buys latency, offline use, and one recall path.
4. Identity and the wire format
Section titled “4. Identity and the wire format”Memory ids are kept end to end. Local rows (one per chunk) have random 64-bit ids (uuid4().hex[:16]), and both stores upsert on id. A pushed row keeps its id on the gateway and in every mirror, so push and pull are idempotent and a forget can name the same row everywhere. (AMA import mints new ids today, so it can’t be the sync path.)
Text, not vectors. Each side embeds with its own model: the gateway may use 1536-dimension OpenAI embeddings, while laptops use 384-dimension bge-small. A pushed record carries:
{"id": "9f2c…", "text": "…", "occurred_at": "…", "tags": ["…"], "fact_type": "…", "metadata": {"_created_at": "…", "session_id": "…", "source": "codex"}, "content_hash": "sha256:…"}_created_at is kept: chunks of one turn share it, and recall regroups them by it.
Duplicates across people. The gateway’s retain dedup already checks the store (#93). A pushed row that near-duplicates an existing team memory is answered with duplicate_of: <id>. The client records the mapping and doesn’t push the row again. First writer wins, and both copies stay findable locally.
Conflicts. A memory’s text is immutable: a push of an existing id with different text is rejected, and nobody edits a memory’s text, so there are no write conflicts on it to resolve. Its provenance and status fields are not immutable: synced records will carry claim status, trust and “may be stale” flags that change after the record is saved. Those will be updatable through a separate endpoint (not push; a push with the same text and different metadata is answered unchanged and changes nothing), and every such change reaches mirrors through the changes feed as an upsert of the same id (§8, G3). Two teammates can still disagree (“we use SQS” vs. a later “moved to Kafka”). That is the same problem as one person changing their mind, and recall already handles it with recency (occurred_at) and observation supersession. Out of scope here.
5. Forgetting
Section titled “5. Forgetting”Forgetting stays an erase, not a hide.
-
Team forget.
astrocyte memory forget <id> --teamcalls the gateway’sforgetby id (needs theforgetpermission). The gateway records a tombstone. Every mirror sees it at its next pull and purges the row: secure delete and VACUUM, asastrocyte memory forgetdoes today. -
Local forget of a shared memory asks for the scope instead of guessing:
This memory is shared with the team (saved by alice). Erase it for everyone with --team (you have forget permission),or hide it on this machine with --local.--localpurges the row and records the id in a local suppression list, so the next pull does not bring it back. -
Legal hold on the gateway blocks a team forget, and the CLI reports it. Holds are in-memory only today (
lifecycle.py), so persisting them is a prerequisite (§8, G4). -
DSAR (
/v1/dsar/forget_principal) forgets by tag, which the pipeline gateway answers with 501 today. Team memory needs forget-by-actor (metadata._actor) to work before it ships to a team with compliance obligations (G4).
6. Auth and provenance
Section titled “6. Auth and provenance”The gateway’s auth is not ready for teams, and this is the first thing to fix (§8, G1):
| Today | Problem for a team | Change |
|---|---|---|
api_key mode: one shared key; the principal comes from the client’s X-Astrocyte-Principal header |
Anyone with the key can act as anyone | Per-user tokens (ASTROCYTE_AUTH_MODE=token): python -m astrocyte_gateway.tokens create --principal user:alice --banks 'project:*' --groups team:api mints a token stored hashed and bound to a principal and its grants; the principal header is ignored. OIDC (jwt_oidc) remains the option for teams with an identity provider |
Grants match a bank exactly or * |
Can’t grant project:* without granting everything |
Prefix and glob matching in grants (project:*), plus a team:<name> group of principals |
_actor is stamped with setdefault, so a client-supplied _actor wins |
Provenance can be forged | The gateway always stamps _actor from the authenticated principal and ignores the client’s |
/v1/import bypasses PII, validation and _actor |
— | The sync push path goes through the full policy layer, like /v1/retain |
How a token’s grants combine with config access_grants (decided in G1): they are added for the token’s principal and never remove anything. Effective permissions on a bank are the union of the principal’s config grants, the config grants to its team: groups, and the token’s own grants. So a team is usually configured once (project:* → team:api: [read, write]) and each token only names its person and groups; a restricted token (read-only CI) comes from keeping that principal’s config grants narrow. Patterns are fnmatch, case-sensitive: project:* matches project:api-1a2b3c, not projectx. Detail: access-control.md §1.3 and §1.5.
_actor is stamped by the core: with a context present it overwrites the client’s value; without one (library imports) a caller’s _actor is kept, and the gateway drops it from anonymous HTTP requests.
Locally, the token is stored in the OS keychain where keyring is available, else in harnesses.json’s sibling team.json (0600). It is never written into agent configs.
Attribution shows in recall and boot: - [2026-10-02] (alice) Q: … → A: …. Your own memories carry no label. The session-start “where you left off” note considers only your own sessions.
7. Client surface
Section titled “7. Client surface”astrocyte team join https://memory.example.com --token … # this project; previews, then confirms the first pushastrocyte team status # gateway, last sync, pending push/pull, permissionsastrocyte team sync [--dry-run] # now, instead of waiting for the daemonastrocyte team leave [--keep-mirror] # stop syncing; by default purge teammates' memoriesastrocyte memory share|unshare <id>astrocyte memory forget <id> --team|--localastrocyte doctor adds a team section: gateway reachable, token valid, permissions per bank, last successful sync, and a backlog that isn’t draining.
Sync state lives in the state directory: a push ledger (memory_id → acked | duplicate_of | rejected), a pull cursor per bank, and the suppression list. It does not go in the memory store, so astrocyte memory export stays clean.
8. Plan (PRs, in order)
Section titled “8. Plan (PRs, in order)”Gateway (server side; each is useful on its own):
| PR | Content |
|---|---|
| G1 | Per-user tokens bound to principal + grants; glob/prefix grants; team: groups; authoritative _actor stamping |
| G2 (implemented) | POST /v1/banks/{bank}/sync/push: batch upsert with client ids through the policy layer; no re-chunking; per-record stored | duplicate_of | rejected |
| G3 (implemented) | GET /v1/banks/{bank}/changes?cursor=&limit=: upserts and tombstones in order. Stores gain a changed_at column (last change to any synced field; backfilled as max(retained_at, forgotten_at)) and an index on (bank_id, changed_at, id) |
| G4 | Persisted legal holds; forget by _actor (DSAR) |
G4 erasure as built (decided 2026-10-05: erase from disk; a DSAR overrides a hold; holds in the gateway’s database). A forget on the SQL stores is a soft delete, which kept a forgotten memory’s text on the gateway’s disk even after a team forget or a DSAR, while mirrors erased theirs. Now:
VectorStore.erase(bank_id, ids)(optional): already-forgotten rows leave the table, their tombstone (id,bank_id,changed_at) moves to a tombstone table (Postgres migration040_vectors_tombstones.sql; SQLiteastrocyte_tombstones), whichlist_changesmerges into its(changed_at, id)order andlookup_idsreads, andinsert_vectorsrefuses an id found there, so mirrors still see the deletion and the id stays forgotten. Postgres also deletes the memory’s temporal facts and refreshes the BM25 views; SQLite rebuilds the file (VACUUM) aspurgedoes. Rows aren’t blanked in place: every read path (as_ofincluded) would then have to skip empty rows. The SQLite/Postgres parity suite covers erase.Astrocyte.erase(needsforget, audit-logged) and/v1/forgetwith"erase": true(only withmemory_ids). Other callers keep the soft delete and itsas_ofhistory.- The team client erases:
forget --teamandunsharesenderase: true. - DSAR by actor:
Astrocyte.forget_principalsweeps the tenant’s banks (configured, pluslist_bankswhere the store has it, which findsproject:*banks), matching_actoror theprincipal:tag, forgets withcompliance=True(a hold doesn’t block it, as before) and erases./v1/dsar/forget_principaluses it; behind an engine provider it falls back to the tag sweep. Known gap: memories soft-deleted before are no longer listed, so a sweep can’t find them by actor. - Persisted legal holds.
Astrocyte.place_legal_hold/lift_legal_hold/legal_holdsstore holds through optionalVectorStore.save_legal_hold/delete_legal_hold/list_legal_holds(Postgres migration041_legal_holds.sql; SQLiteastrocyte_legal_holds; in-memory), so a hold survives restarts and binds every replica.forgetandrun_lifecyclere-read the bank’s holds before checking: one more query per forget, the price of no stale cache across replicas. The gateway’s/v1/admin/banks/{bank_id}/holdendpoints and the MCP hold tools use them;GETlists the holds. A held bank answers a forget with 423 Locked (it was an unhandled 500), andastrocyte memory forget --teamsays the project is under a legal hold. A DSAR is not blocked (decided). The synchronousset_legal_hold/release_legal_holdstay, process-local as before. - Not erased: rows derived elsewhere from a memory (entity links in a graph store, wiki pages, mental models, source documents a retain stored); the
observations/invalidateand mental-model endpoints remove those.
G3 as built. Astrocyte.list_changes(bank_id, cursor=, limit=, settle_seconds=) over an optional VectorStore.list_changes(bank_id, *, after, limit); the gateway serves it at GET /v1/banks/{bank_id}/changes (needs read; 501 when the store has no feed). Decisions the plan left open:
- The changes feed is every change to a synced row, in
(changed_at, id)order — not only stores and forgets. Synced records will later carry claim status, trust and “may be stale” flags that change after the record is saved, sochanged_atis the row’s last change to any synced field, and a live entry carries the row’s current values: a later status change reaches mirrors as an upsert of the same id. Every store setschanged_aton every write that changes a row: an insert takes the row’sretained_at; an overwrite throughstore_vectors(a restore of a forgotten id, or a metadata rewrite that keeps the oldretained_at, as the temporal-normalisation task does today) takes the time of the write; a forget takes the time of the forget. There is no other update path in the stores today; any future one (status, trust, staleness) must bumpchanged_attoo, or mirrors never see the change. - Postgres backfill without a rewrite. Migration 039 adds
changed_atas a NULL column (catalog-only) and indexes(bank_id, COALESCE(changed_at, GREATEST(retained_at, forgotten_at)), id COLLATE "C"); the store readschanged_atthrough that exact expression. Rows written before the migration therefore read asmax(retained_at, forgotten_at), the backfill value, until their next write sets the column. AnUPDATEbackfill would write a new version of every row into the DiskANN index, and a generated column would rewrite the table under an exclusive lock and rebuild every index. SQLite adds the column and backfills it on open (local stores are small); the in-memory store tracks it and remembers tombstones beside its hard deletes. - Ids order byte-wise (
COLLATE "C"on Postgres,BINARYin SQLite), so the order and the cursor don’t depend on the database locale, and SQLite and Postgres agree (the parity suite checks mixed-case and_/-ids). - Cursor. URL-safe base64 of
{"changed_at", "id"}, opaque to clients; one the server can’t parse is a 400 (InvalidCursor). A page returnsnext_cursor(the position after its last change, or the request’s own cursor when empty, so a caught-up client keeps its place) andhas_more. - Tombstones carry nothing but
id,deletedandchanged_at, so a forgotten memory’s text never leaves the gateway again. A live entry includesmemory_layeras well as the planned fields, so a client can tell server-side observations and mental models from teammates’ memories (whether mirrors should pull those is for C1/C2). - Settle window.
changed_atis stamped just before a write commits, so with concurrent writers a slow commit could land behind a cursor that already moved past it, and that row would never be pulled. The gateway holds back changes younger thanASTROCYTE_CHANGES_SETTLE_SECONDS(default 5 s; the library default is 0). This assumes commits take less than the window and that the gateway hosts’ clocks agree; a feed that needs a hard guarantee would order by a commit-time sequence instead. - Retention of tombstones. Soft-deleted rows (and so tombstones) are kept indefinitely today; nothing purges them on the gateway. If a purge is added, a mirror whose cursor is older than the purge horizon must re-sync from scratch. The local SQLite store’s
astrocyte memory forgetpurges rows outright and so leaves no tombstone, which is fine: it doesn’t serve a feed.
G2 as built. Astrocyte.push_records(bank_id, records, context=) runs each SyncPushRecord through the retain policy layer and hands the survivors to the pipeline, which stores each as exactly one row with the client’s id: no chunking, no extraction, no LLM call; the server re-embeds the text. The gateway serves it at POST /v1/banks/{bank_id}/sync/push (needs write). Two new optional store methods: lookup_ids(ids) (the current state of each id in any bank, live or tombstone) and insert_vectors(items) (insert-only, ON CONFLICT (id) DO NOTHING, returns the ids inserted); Postgres, SQLite and in-memory implement both, other stores answer 501. Per record:
| Result | When |
|---|---|
stored |
New id; a row with exactly that id |
unchanged |
The id holds the same text in this bank. If metadata, tags, fact_type or occurred_at differ, they are not applied and reason says “text unchanged; metadata updates are not accepted by push” (status/provenance changes get their own endpoint) |
duplicate + duplicate_of |
A new id that near-duplicates a memory of this bank (the retain dedup check: the in-process cache, then the store’s nearest neighbours, bank threshold and negation guard). Nothing stored. With signal_quality.dedup.action: warn it is stored instead |
rejected + reason |
The id holds other text in this bank or exists in another bank (one wording for both, so a push can’t probe other banks; the other bank’s row is never overwritten, moved or shown); the id was forgotten in this bank (a forget is not undone by a re-push); or the policy layer refused it (size, validation, PII reject, content_hash mismatch) |
Decisions the plan left open:
- Ids are global, so the write is insert-only. Both stores key rows on
idalone. The pipeline looks every pushed id up across banks first, and writes withinsert_vectors, so even a race (two pushes of one id, or a push and a retain) can’t overwrite: the loser is re-classified from the row that won. The same id twice in one batch: the second isunchangedorrejected. - Policy, as
/v1/retain: input size and tag limits, content validation, the PII barrier (redact rewrites the stored text;rejectrejects that record only, not the batch), metadata sanitization, authoritative_actorfrom the authenticated caller. Underscore-prefixed metadata keys are system-owned: a push keeps_created_at,_retain_id,_chunk_index(the reader regroups chunks by them) and drops the rest (_authority_tier,_mip.*, …), which a client could otherwise use to change how recall ranks its memory. MIP routing is not applied: the bank is fixed by the URL and the ids. - Rate limits and quotas count a push as one call (checked once, before anything is stored; 429 as usual), and quota usage is recorded per stored record, so a quota can be overshot by at most one batch. Counting each record against
retain_per_minutewould make a 100-record sync trip any realistic limit. - Request validation is 400, like every other gateway endpoint (the gateway translates FastAPI’s 422; see
models.py): more than 100 records, an id outside[A-Za-z0-9_-]{8,64}, empty text, a malformedcontent_hash, non-scalar metadata values. content_hash, when sent, must equalsha256:+ the hex SHA-256 of the UTF-8 text as sent (before any PII redaction); a mismatch rejects the record. It is not stored.- “Unchanged” compares the stored text with the pushed text after PII redaction, so a redacted record re-pushed unchanged stays
unchanged.
Client:
| PR | Content |
|---|---|
| C1 | astrocyte team join/status/sync/leave: manual push and pull, preview, keychain token |
C1 as built (astrocyte/harness/team.py). Membership is per project bank, in team.json beside the config; the token goes to the OS keychain (keyring, optional) or that file (0600). Sync state per bank is in the state directory: the push ledger (id → stored | unchanged | duplicate:<id> | rejected:<reason> | forgotten), the ids pulled from teammates, and the feed cursor. Decisions made while building it:
- Push sends
fact-layer rows only, neverobservation/modelrows (derived locally, re-derivable on the server), and never a pulled id. A rejected or duplicate id is recorded and not re-sent; text is immutable, so it would get the same answer. - Pull writes through the local
push_recordswith no caller context, so the teammate’s_actor(stamped by the gateway) is kept, and the local policy layer and dedup apply. A teammate’s memory that near-duplicates a local one is skipped (counted, not stored). Server-made observations and mental models are not mirrored (§8, G3 left this to the client). - A tombstone erases the id here (forget, then purge from disk), including a memory of yours that a teammate forgot for the team; your ledger then marks it
forgottenso it is never pushed back. - Metadata changes after the first pull are not applied yet: a later upsert of a pulled id is answered
unchangedlocally. Status, trust and staleness fields don’t exist yet; when they do, they need a local update path (C2). - Each acknowledged push batch and each applied pull page is saved before the next request, so a failure mid-sync resumes without re-sending or re-applying. | C2 | Background sync in the agent daemon; tombstones purge mirrors; forget scopes; attribution in boot and recall |
C2 as built. The agent daemon syncs every joined project on its first housekeeping pass and then every ASTROCYTE_TEAM_SYNC_SECONDS (300). Decisions made while building it:
- One sync per bank at a time, across processes: a lock file beside the bank’s sync state. The daemon skips a bank another sync holds; the CLI says so. A failed sync keeps its error in the state (
team statusshows it); the next interval retries. - The daemon syncs through a second
Astrocytesharing its pipeline, with noisy-bank detection off: a first pull of a team’s memories is exactly the burst that check refuses, and the shared pipeline means no second embedding model. - “Teammate’s memory” means an id this machine pulled, from the sync state, not “has
_actor”: local memories saved through the MCP server carry_actortoo (agent:mcp, or the configured principal). Labels are(alice)foruser:aliceand the full principal otherwise ((service:ci)), in session-start, prompt recall, file recall andastrocyte memory list(saved_byin--json). - “Where you left off” skips teammates’ captured turns (only shared when a project opts in), as §6 says: it is your own previous session.
- Forget scopes.
forget <id>on a memory that is on the gateway (pulled, or pushed and kept) refuses without--teamor--local.--teamforgets it on the gateway first (/v1/forget, needsforget; a legal hold or 403 stops it before anything local changes), then here; the ledger marks itforgotten.--localerases it here and adds it to the bank’s suppression list, which pull honours even when the feed is replayed from the start.forget --allon a shared project is local only and suppresses what it erased; a memory never shared is forgotten as before, with no prompt. | C3 |share/unshare; opt-in captured sharing; doctor’s team section; docs |
C3 as built. Decisions made while building it:
shareandunshareare per-memory overrides kept in the bank’s sync state (promoted,withheld), not tags: they work before a project joins and never change the memory itself. Either applies to every chunk retained with the memory (same_retain_id).shareoverridescapturedandprivate; a teammate’s memory is skipped.unshareof a memory already on the gateway forgets it there (/v1/forget, needsforget), so teammates’ copies go at their next sync. Your own copy stays: a tombstone for a withheld id doesn’t erase it here. The gateway keeps a forgotten id forgotten, so an unshared memory can’t be shared again under its id;sharesays so. A teammate’s memory can’t be unshared (forget --localhides it).- Opt-in captured sharing is changed by running
team joinagain, which previews what would go up first. Turning it off stops further captured turns; ones already pushed stay until unshared. doctoradds a line per joined project: fail without a token or when the gateway refuses or can’t be reached; warn when the last sync failed or is older than a day; info before the first sync. It reports the last sync rather than a push backlog, which would need the store open.- Not built: applying later metadata changes to pulled memories. Nothing on the gateway changes a stored memory’s metadata yet, so the feed never re-sends a changed live row; the local update path waits for claim status, trust and staleness fields (
anchored-documents.md).
Testing. An end-to-end CI job runs the gateway container with two local users (two HOMEs) and checks that:
- Alice’s agent saves a decision, it syncs, and Bob’s
session-startand prompt recall show it, attributed to Alice; - Alice runs
forget --teamand Bob’s mirror purges the row from disk; - a token without
forgetis refused; - a push made offline is queued and drains later;
- a forged
_actoris ignored.
9. Decisions (2026-10-04)
Section titled “9. Decisions (2026-10-04)”- Captured turns stay local by default. A project opts in with
team.share_captured: true; single turns can be shared withastrocyte memory share <id>. - Auth: per-user gateway tokens (G1) for teams without an identity provider; OIDC remains supported for those with one.
- Granularity: the whole project bank, with per-memory
private/unshareas the exception. - Leaving a team purges teammates’ memories from the local mirror by default;
--keep-mirrorkeeps them.
Added 2026-10-05, after the anchored-documents direction (anchored-documents.md):
- A shared claim cites a shareable source when one exists, and a redacted excerpt otherwise. Every claim cites evidence, but captured turns stay local (1), so a citation to a private turn would dangle on teammates’ machines. A team claim cites a document, commit or PR every teammate can open; when there is none, it carries the PII-redacted verbatim excerpt of the turn it came from, and that claim’s trust is capped below one with a shareable source.
- C1 syncs memories, on G2/G3 as built. Decisions, conventions and imported docs sync as memories now; claims and pages join the same push and changes feed later as rows with their own fields, rather than C1 waiting for the document layer.