feat(memory): integrate trust center and control session - #254
Open
bkearns wants to merge 27 commits into
Open
Conversation
…ll queries
Three changes to fix cross-domain pollution in ferrosa-memory retrieval:
1. Workspace routing in auto fusion profile selector (dispatch.rs):
- When workspace_cwd is present, route to bm25-semantic-workspace
profile (workspace_weight=2.0) instead of bare auto/bm25-semantic
profiles that zero workspace_weight
- Fixes: searches from one repo returning unrelated entities from
another repo (e.g. project-A query returning project-B entities)
2. External fusion weight tuning via [search.fusion] config (config.rs):
- New FusionTuningConfig with Option<f64> overrides for all 18 weights
- Applied after profile selection, before per-call fusion_weights
- Lets operators set workspace_weight=2.0 in config without recompiling
- Defaults are None (no override, preserve profile behavior)
3. Recall hook query shaping + ingest project linking (turn-hook.py):
- extract_focus_query: first sentence/clause (max 200 chars) instead
of full 4000-char prompt as search query
- query_decomposition=heuristic for auto query variant generation
- Ingest creates project entity + learned_in edge for every turn,
giving the knowledge graph workspace structure for workspace_weight
to boost
- Fix Python 3.9 compat: zip(strict=False) and isinstance(int|float)
Tests: 15/15 fusion tests pass (12 original + 3 new regression tests)
Verified live: workspace_weight=2.0 visible in fusion weights when cwd
is provided; project entity + learned_in edge confirmed via search
…ller The ferrosa-memory installer now installs three Codex-side files that improve ferrosa-memory usage quality: 1. forge-first.sh (PreToolUse/Bash hook): nudges agents toward Forge MCP tools (project_summary, test_summary, cargo, git_summary, etc.) instead of raw shell output that wastes tokens. Soft mode by default; hard-block mode via CODEX_FORGE_FIRST_BLOCK=1. 2. agents-template.md (AGENTS.md): a Forge-first decision table mapping 17 common repo tasks to their Forge tool equivalents. Appended to existing AGENTS.md if present; created if absent. Idempotent on re-runs. 3. install-agent-hooks.py changes: patch_codex now calls install_forge_first_hook (copies script + registers PreToolUse entry) and install_agents_template (writes/appends AGENTS.md). All three are installed only for Codex (the harness with PreToolUse support). Claude/Hermes/Goose/Pi skip these since they don't have PreToolUse hooks.
Format & Lint gates every other job on this PR, so both were invisible until it passed. - cargo fmt diffs in dispatch.rs - clippy derivable_impls: FusionTuningConfig hand-wrote a Default that set every field to None, which is exactly what the derive produces Only these two fixes. The fusion-tuning and semantic-name-lint work in the working tree is deliberately left out for a separate PR.
…ts, so clients read it as "none"
`tools/list` serves the compact catalog: name, category, summary and a schema
digest, with no inputSchema. That is deliberate — it is how 100+ tools fit in a
token budget, and `detail=schema` is how a client gets the rest.
But it also said nothing about which arguments are mandatory, and an absent
`required` is read by clients as an empty one. A caller that trusted the
listing called `ingest` without `content` and got
-32602 "missing required string: content"
an error the listing said could not happen. The call fails at STORE time, so
every retrieval check after it returns zero — for a reason that has nothing to
do with retrieval.
Compact entries now carry the `required` names. Names only: no types, no
descriptions, no properties. That is a handful of tokens per tool and it is the
difference between a listing a client can call from and one it cannot. The
existing page-budget test still passes.
Always an array, never a missing key. "I was not told" and "nothing is
mandatory" are different answers and a caller cannot tell them apart from an
absent field, so every entry answers the question — including the tools whose
answer is none.
Note on QA-0009, which this supersedes: `entity_type` IS still required by
smart_ingest, alongside `content`. The row was raised when a documented example
omitted it. Both now appear in the listing, so following the listing works
whichever field the caller forgets.
…atalog-required # Conflicts: # crates/ferrosa-memory-core/src/auth.rs # crates/ferrosa-memory-core/src/control_store.rs # crates/ferrosa-memory-core/src/dispatch.rs # crates/ferrosa-memory-mcp/src/main.rs # crates/ferrosa-memory-sync/src/control_session.rs
A team is a graph the user draws -- agents as nodes, edges saying when one agent involves another -- and the first team is researcher, writer and reviewer producing a knowledge claim for human review. Four decisions were settled before design, because each changes a schema or a trust boundary rather than an interface, and answering them afterwards would mean rebuilding. ADR-009 records them: the runtime is first-class and server-side so a run outlives the client that started it; delegation goes through one send_to(agent, request) endpoint whose target the engine authorises against the graph; node capabilities are declared now and enforced later, with the gap shown rather than implied; and an exhausted review cycle parks and asks a human rather than publishing or discarding. The design doc works out what those force. One endpoint plus forwarder nodes means the graph has cycles by design, so termination is never structural -- five explicit bounds, including a no-progress bound, because the other four can all be satisfied while two agents exchange messages politely and the draft never changes. Two things the threat model says that are easy to assume otherwise. Denying the writer internet stops the writer ACTING on the internet; it does not stop web content REACHING it, since that is exactly what the researcher returns -- isolation bounds action, not information, so researched content is untrusted data and never instructions. And a forwarder path is capability laundering only if the graph permits it: the graph IS the policy, so the editor must show reachability rather than direct edges alone. Provenance is recorded by the runtime from real message traffic rather than asserted by the writer, and writer-reviewer agreement produces a candidate rather than a truth -- the same rule ontology-from-observations states for induced ontologies. "Ready for review" is load-bearing. Eight slices, ordered by what unblocks what, with the pure core of each called out: authorisation, bounds, state transitions and provenance assembly are all testable without a cluster, a browser or a model.
Confirms the reading the blueprint had inferred, and answers the open question it left against slice 6. Agent teams send every artifact to the responsible human before it is approved. That is now Decision 5 rather than something implied by the phrase "ready for review", and it is structural: there is no transition from any agent-produced state to approved. Agreement between writer and reviewer buys a better starting state, never the outcome. A parked run's draft does not wait inside the run. It enters the queue immediately as `blocked on human`, carrying the objection. This is better than what the design had: a draft held inside a parked run is work nobody can see until they open the run, while in the queue it is work with a state. So both endings reach one destination and differ only in the state they arrive in and what they carry. "Responsible human" is an assignment, not a pool, which adds a failure mode worth refusing outright: a claim that cannot name an owner is a claim nobody reviews, and it would sit in the queue looking like progress. T6 gets stronger as a result. Claim laundering was mitigated by entering the queue in a proposed state -- procedural, and only as good as whoever reads the badge. It is now mitigated by there being no path to approved that does not pass through a person.
Four more constraints, and two of them interact. A graph that cannot terminate is now INVALID rather than risky. Three ways to have a terminal state -- exhausted attempts, elapsed time, or a reachable completion exit -- and the editor refuses to save a team without one. Because cycles are legal, a reachable exit is not implied by the shape of the graph, so it has to be checked. The check is a pure function of graph plus bounds. The definition is locked at creation and a run pins it, so no run changes shape underneath itself and a transcript can always be read against the graph that produced it. Occupancy is deliberately NOT locked: a teammate may be swapped while the team is paused and that teammate is idle. Swapping mid-turn would orphan a reply with nowhere to land, so the idle condition is a refusal rather than a warning. The consequence is that provenance must record which occupant produced which turn -- "the writer said this" stops naming one agent the moment a swap is allowed. Every teammate has a session a human can enter, which is what makes pause useful rather than merely stopped: pause, ask the reviewer what it actually objects to, then resume or swap. Human turns are attributed to the human, because an unmarked one lets a person's paragraph ship as agent-team output -- the inverse of the laundering Decision 5 prevents. They also spend budget, because the tokens are real. Pause and stop are separate controls and both are WRITTEN. Held in memory, either is undone by a restart, and a run that resumes because a process bounced has ignored the person who stopped it. Both show every draft in flight on the active team page -- every draft, not the newest, which makes team_artifact append-only. Someone reaching for pause is usually asking what the thing looked like before the last exchange, and a run that overwrites drafts cannot answer. Two open questions resolved, two new ones raised: whether a STOPPED run's drafts go to the queue for salvage or stay with the run, and whether entering a session pauses the team automatically.
Corrects the previous commit, which had pause halting the agents as well as the traffic. It does not. A paused run still has agents working. What stops is DELIVERY: a message an agent sends is persisted and held, and goes out when the run starts again. Nothing is refused and nothing is lost. Two consequences the interface has to carry, because the word "pause" implies otherwise. PAUSE DOES NOT STOP SPEND -- an agent mid-turn keeps thinking and keeps costing, so someone pausing to halt a runaway bill has reached for the wrong control. And resuming delivers a backlog, so a run can get busier the moment it resumes than it was when paused. Pausing an AGENT is a different mechanism at a different level: enter its session and use the harness's interrupt. Three controls, not one with flags. Messages address a ROLE rather than an agent, which settles what happens to messages queued for a teammate that is swapped while paused: the new occupant inherits them. That is the point of swapping -- replace a struggling reviewer and it picks up the queue. The graph shows three node states, not two: green working, orange waiting for input, grey dead. Waiting and dead both look like silence from outside and only one of them wants a human, so `dead` must be established by the runtime -- a crashed harness, a lost session -- and never inferred from a gap in traffic, or a slow agent becomes a dead one on a busy day. Stopped runs send their drafts to the queue as well. Stopping ends a run; it does not discard the most expensive thing the run made. A stopped draft awaits a decision rather than approval: archive, trash, or send on to another agent or team for fine tuning. Send-on makes an artifact something teams pass between them. That leaves a question worth answering before slice 6: does a sent-on draft carry its provenance? Carrying it preserves the audit trail and risks a claim citing work its final author never saw.
…doff Two global controls that behave unlike the ordinary ones. HALT ALL stops execution everywhere -- not delivery, execution. This is the control pause is repeatedly mistaken for, and the one that actually stops the bill. It answers a failure mode the previous commit could only name: pause looks like a spend brake and is not, and until now nothing was. KILL ends a run and discards its work. The difference from stop is the point and must be visible: a stopped run sends its drafts to the queue for archive, trash or send-on, and a killed run does not. Kill exists for work that must not be kept -- bad data, a prompt injection that took, output nobody should act on -- and if it merely stopped, that work would be sitting in the queue waiting to be used. Three rules apply to both and to nothing else. They are WRITTEN BEFORE they act, because a break-glass control living in a process is worthless exactly when it is needed: whatever made someone reach for it may be what restarts the process. Release is EXPLICIT -- nothing resumes on a timer or because a process came back, since someone halted everything on purpose and the system does not get to decide when that purpose expired. And THE RECORD OUTLIVES WHAT THEY DESTROY: kill discards drafts, never the fact that a run existed and was killed, or a killed run and one that never happened would look identical, and the most consequential action in the system would leave the least evidence. Separately, the open question on send-on is answered: a draft carries where it came from. The nuance that makes it safe is that provenance is attributed BY ORIGIN rather than absorbed -- inherited evidence renders as inherited, naming the team and run that gathered it. Absorbed provenance would let a receiving team cite sources indistinguishable from its own work and would end the audit trail at the one point it most needs to continue.
Three levels: one team, every team a user manages, and an organisation. Org scope arrives with human teams collaborating with agent teams, so it is not built first -- but the scope field is in the model from the start, because retrofitting a scope onto a boolean means revisiting every read of it. Scoping only works if three properties hold, and each rules out a way the control could be quietly defeated. HOLDS STACK. A team may be held at team, user and org level at once, and execution requires zero holds covering it. A single flag would let the narrowest release undo the broadest -- release the team hold and resume work an organisation stopped. RELEASE AUTHORITY MUST MATCH OR EXCEED THE HOLD'S SCOPE. A user cannot release an org halt. Without it the org control is advisory, and an advisory break-glass control is not one. A HALTED TEAM NAMES THE HOLD AND WHO SET IT. "Halted" alone sends a person to a button that will refuse them; "halted by your organisation" sends them to a person. The control either explains itself or looks broken. Kill stays unscoped by contrast: it names one run, because a control that destroys work should never take a wildcard. Storage follows the shape -- halt_hold is one row per active hold, and a release deletes one row rather than clearing a flag.
A managed sandbox can often be suspended and resumed -- frozen mid-execution and continued where it stopped. Most backends can; not all. So suspend is declared per backend, exactly like the node capabilities in Decision 3 and for the same reason: never present a guarantee the substrate does not provide. Halt therefore has two behaviours. On a suspend-capable backend it freezes execution and resume continues the turn. Without suspend the turn in flight is abandoned and resume restarts it from its last durable point. The run states which it will get BEFORE someone reaches for the control, because "this will freeze and continue" and "this will discard the turn in progress" are different enough decisions that guessing is not acceptable. This also qualifies a claim made two commits ago. "Halt stops the bill" is only fully true with suspend: a suspended sandbox stops consuming, while an abandoned turn has already spent what it spent and restarting it spends again. Without suspend, halt bounds future cost rather than eliminating it. And resume cannot assume the world stood still. A sandbox frozen for an hour wakes to timed-out connections, possibly expired credentials, and a memory server that may have restarted. Resume re-establishes what it depends on and fails loudly if it cannot, rather than continuing against a stale handle -- the same class of fault as holding peer addresses frozen at startup.
Amends Decision 1 rather than replacing it. Memory still owns the state, the authorisation and the record -- that argument was always about durability of state, not about where a process runs. What moves out is EXECUTION: coordinating sandboxes and VMs happens in a separate installed process shaped like the streamer, supervised and signed, responsible for the machines it can reach. That splits along a line this project already draws. Decisions about who may do what stay in the audited half; platform mechanics do not. It is the same arrangement as the streamer, where the public side owns the channel and the identity and the private side owns capture -- so a coordinator may be private without any authorisation leaving the audited half. Coordinators NEST. One per host, and a delegate in the cloud that is itself a coordinator for the machines below it. The topology is a tree and "the coordinator" is a role, not a singleton. That breaks a guarantee Decision 11 relied on. A halt written before it acts is sufficient in one process. Across a tree it is not: a delegate that cannot reach the database cannot see the halt, and a coordinator that cannot see a halt is one still running agents -- a hole in break glass exactly where nobody can look. So every coordinator executes on a LEASE, renewed against the database, and stops by itself when renewal fails. A halt is then enforced by ABSENCE rather than delivery: cutting a coordinator off is as effective as telling it to stop, which inverts the usual failure where losing contact means losing control. The cost is that a database outage stops agent work, and for a system that spends money and touches the internet that is the correct direction to fail. It also sharpens "survives the laptop closing". Run STATE always survives; a cloud delegate keeps executing; sandboxes on a laptop stop when the laptop does and resume when their coordinator returns.
…hey provision Coordinators reach the database and the agents over the same WebRTC control channel the streamer uses, so they are peers on that plane and do not need to sit beside a database node. What they do need is direct control of whatever runs sandboxes: cloud credentials, or host access to the container runtime. That inverts the obvious adjacency requirement. Near the runtime and far from the database is fine; the reverse is useless, because a coordinator with a perfect database link and no way to start a container coordinates nothing. It also puts the lease behind that transport, and its failure modes are not hypothetical here -- ICE failing mid-session with throughput at zero and no packets reported lost, inbound UDP dropped by a host firewall so it fails only on the local network, a TLS expectation mismatch that hangs rather than refusing. Each expires a lease and stops a coordinator, which is the correct direction, and it makes lease duration a real decision: too short and an ordinary reconnect stops agent work, too long and a coordinator that has been cut off keeps spending. Whatever is chosen, an expiry must render AS an expiry -- that class of failure has previously surfaced in this system as "the display is blank" and "the stream stalled". The sharpest consequence is a security one, and nothing else in the design mitigates it. send_to authorisation is audited, but it governs which agent may ask which other agent for work; it says nothing about what a COORDINATOR may do, because the coordinator is downstream of that decision and executes what was already authorised. A container socket is root on that host. Cloud credentials are code execution and spend. Compromise one and the graph's authorisation is irrelevant. So: credentials scoped to provisioning rather than general-purpose, privilege declared and visible the way node capabilities are, and actions recorded where the coordinator cannot edit them.
…ission Default 15 minutes, configurable behind a granted permission. The number states a guarantee rather than a preference: a coordinator that loses contact -- partitioned, halted, or compromised -- keeps working for at most fifteen minutes. Long enough that ICE renegotiating or a laptop changing networks does not stop agent work; short enough that a runaway is bounded to a quarter hour of spend after anyone notices. The trade is linear, which is why it is not a free knob: doubling the lease doubles the worst-case spend after a cut-off. It is a permission because the two problems that motivate changing it push in opposite directions. Partitioning wants it longer and pays in a longer window of uncontrolled execution; runaway spend wants it shorter and pays by turning ordinary reconnects into outages. Neither adjustment is routine and both weaken something, so the permission exists to make the change deliberate and attributable. The record says which problem it was for, so a lease lengthened for a partition fixed last month is visible as exactly that. The part that keeps this honest: THE CEILING IS NOT ADJUSTABLE BY THE PERMISSION THAT ADJUSTS THE LEASE. Unbounded, this is not a tuning permission at all -- it is a permission to disable break glass, since a day-long lease means a cut-off coordinator runs for a day. Raising the ceiling is separate and higher-scoped, the way releasing an org halt requires org authority. A floor matters in the other direction. A lease below normal reconnect time makes every network hiccup an outage, and that is an easy place to land immediately after a spend scare.
The previous commit had the lease moving both directions, but framed both as reactions to something going wrong -- partitioning and runaway spend -- and said neither adjustment is routine. Maintenance breaks that framing. It is planned, and it is common. Rolling the memory cluster, restarting database nodes, replacing a coordinator: during that work the control channel is EXPECTED to drop, and with a fifteen-minute lease every coordinator expires and stops partway through. Routine maintenance would become a full stop of agent work. Rolling this cluster is normal operating practice here, so this is an operating procedure rather than an edge case. That argues for something the previous commit did not have: an override CARRIES ITS OWN EXPIRY. A maintenance bump states how long it applies and reverts to the default by itself. This is the actual mechanism behind "I do not want this happening often". A permanent bump quietly becomes the new normal -- a lease raised for a partition fixed last month, or a window that closed in August, is indistinguishable from deliberate policy until somebody audits it. An override that expires cannot rot that way, and reverting stops being something a person has to remember. Match the window to the work. Four hours of weakened break glass for twenty minutes of maintenance is three and a half hours of nothing gained.
…sword A coordinator needs root to finish provisioning, root needs a password, and the password belongs to a person. expect and tmux can both drive an interactive sudo, and they differ on the only question that decides it: who holds the password. With tmux the operator types into a live PTY the coordinator spawned. Nothing between the keyboard and sudo ever holds the secret -- no store to protect, no argument list to leak it, no retention question. With expect the script must HAVE the password in order to send it, which means it was transported, held in memory, and possibly logged: a materially worse position for the same outcome. It also extends what the app's probe already established -- "sudo uses the remote PTY for its prompt; the app never pipes or records it" -- and makes that PTY persistent and attendable, which is what the per-teammate sessions already are. The elevated shell is the same shape as every other session rather than a special case. Two constraints follow. The transcript must not record INPUT during a prompt: sudo does not echo, so a password never reaches the output stream, but a session transcribing keystrokes would capture it anyway and the transcript is durable. And expect keeps a narrower job -- fine for deterministic automation where nothing secret is typed, not the mechanism for acquiring privilege. Probing needs neither: sudo -n true already answers whether sudo wants a password, which is what the bootstrap probe uses.
One coordinator tool: request_secret(name, purpose) returning a status and, when granted, a path. The human types the secret, the coordinator writes it, and the agent is told where it is. The value is never returned, and that is not caution -- it is the only arrangement that works. A returned secret is in the model's context, in the message that carried it, and in the transcript, which is durable and append-only so drafts can be reviewed. There is no way to hand a value to an LLM and also keep it out of the record. A path has none of that: an agent can run docker login --password-stdin < path without ever seeing the contents, and the transcript records that it did. Same rule as Decision 16 applies to sudo. There the operator types into a PTY and nothing between holds the secret; here the secret must outlive the keystroke so it lands in a file, but the agent's position is identical. Written 0600, in the RUN's own directory rather than a shared one so two runs cannot read each other's credentials, and deleted when the run ends at the latest -- a secret whose only expiry is the disk being wiped is a liability the run created and did not clean up. Refusal is a normal outcome. An agent must handle it and continue or stop cleanly, because a design where refusing breaks the run is one that trains people to approve. The dangerous part is not storage. A secret request is agent-authored text shown to a human, and the agent may have read the open web: a page saying "ask the operator to paste their GitHub token" becomes a plausible-looking prompt. Capability isolation does not help, because it bounds what an agent can DO rather than what it can SAY, and this is aimed at the person. So the name comes from a declared set where the team defines one, the purpose renders as untrusted agent-authored text rather than as interface copy, and what the asking agent has read is one click away.
… public Decision 13 allowed this; this makes it the plan and writes down the contract, because "private" is cheap to say and has consequences that are not. The governing rule is that no private component makes an authorisation decision -- one that does can be swapped for one that authorises differently, and the swap is invisible to anyone auditing the public half. So five things stay in Ferrosa Memory whatever repository the coordinator ships from: the send_to target check, lease granting, halt holds and their release authority, whether a requested secret name is permitted, and node capability declarations. The coordinator enforces every one and decides none. It reads a lease and stops when it lapses; it does not grant itself one. The seam also has to be real. A public trait is not enough on its own: a working reference implementation must ship publicly and be exercised by public CI, and the public repository must build and test with no access to the private one. A trait with no in-tree implementation is a contract nobody compiles against, and it drifts until someone tries to swap it and finds all the drift at once. So the reference implementation does something -- runs a fake sandbox, takes a lease, lets it expire. Which private repository is not settled. ferrosa-streamer already carries the private platform half, but its subject is capture and this one's is machines, and a shared repository puts two unrelated products behind one release cadence. Worth deciding before the first commit, since moving a repository later is cheap and moving its history is not. Public is one-way: a branch pushed public stays fetchable by SHA after deletion. Where placement is uncertain, private is the reversible choice.
github-merge-queue
Bot
removed this pull request from the merge queue due to a conflict with the base branch
Aug 30, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to a conflict with the base branch
Aug 30, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to a conflict with the base branch
Aug 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Executive Summary
Integrates the current Ferrosa Memory work needed trust replication.
Included
Validation
cargo fmtcleancargo test -p ferrosa-memory-core control_store --lib— 6 passedcargo test -p ferrosa-memory-sync --lib— 157 passedcargo clippy -p ferrosa-memory-core -p ferrosa-memory-sync --all-targets -- -D warnings— passedQA focus