Skip to main content

ADR 0148 — The agents' artifacts projected to files, in a docs/ folder

Context​

Every artifact the agents produce — the Criativo's (Creative's) brief, the PO's business rules, the Arquiteto's (Architect's) C4 diagram and module routing, the decisions of the six conversational agents — lives exclusively in the event log. Engine.Harness.Tools.EmitArtifact (apps/engine/lib/engine/harness/tools/emit_artifact.ex:3-5) is explicit: "não há tabela de artefatos". The artifact IS the artifact.<tipo> event, immutable, with an author and a seq.

That is the right decision for the SOURCE, and it is not touched here. The problem is another: that memory never reaches any place a person opens. It is readable through the session timeline, one line at a time, inside the product. Whoever opens the project folder in the editor — which is where the code will be — finds nothing of the reasoning that produced that code.

The hole got bigger with the decision to defer git (deferred provisioning, in its own PR): between the project's creation and the Arquiteto's handoff to the Dev Lead, the project exists without any repository. In that interval, everything the product has accumulated about the project is in database rows, and the user's folder is empty.

There is an exact precedent in the product to solve this, and it has already been discussed and accepted: the Neo4j graph (ADR 0099/ADR 0101), which is declaredly memory derived from the event log — never a source, able to be discarded and rebuilt. GraphProjector (apps/api/src/application/graph-projection/graph-projector.ts) drains outbox_events under its own aggregate_type and writes to an external destination, degrading without ever bringing down whoever feeds it.

The question of this decision is not whether the folder should exist — the product owner asked, literally, "criar apenas uma pasta por ora chamada docs, onde os artefatos estarão de cada um dos agentes". It is with what mechanism, and what that requires guaranteeing.

Decision​

The docs/ folder is a PROJECTION of the event log, in the same strict sense ADR 0101 gave the graph. The source remains the event; the folder can be deleted entirely and rebuilt; and nothing in the product reads from it to decide anything. It exists to be opened by a person.

Everything else follows from that.

1. The mechanism is that of GraphProjector, piece by piece​

ArtifactProjector (apps/api/src/application/artifact-projection/artifact-projector.ts) copies the shape that already solved this problem: its own poller (setInterval with .unref(), cleaned in onModuleDestroy), a public drainOnce() independent of the timer (it is the tests' entry point), a draining flag against overlapping cycles, a batch of 50, and markProcessed only after success.

It is not code reuse, it is design reuse — the two projectors write to destinations with nothing in common. Copying the shape is what makes a reader who knows one understand the other without rereading.

2. Its own aggregate_type, artifact_projection​

AppendSessionEventUseCase writes a third outbox row, in the SAME transaction, for the types in ARTIFACT_PROJECTABLE_EVENT_TYPES. The aggregate_type is new for the same reason as the graph's (documented in domain/graph/graph-projection-events.ts:7-26): Engine.Outbox.Drain.run_once/0 only drains session, task and container, and the 'session' row every event already has is marked processed_at by it every ~2s. Reading the same aggregate_type would be racing the engine's drain and losing almost always.

One deliberate difference relative to the graph: the aggregateId is the project, not the session. The folder belongs to the project, and the source event only carries sessionId — recording the projectId here spares the projector a query per artifact to discover something the use case already had in hand.

3. An ALLOW list, and four types deliberately out​

ARTIFACT_PROJECTABLE_EVENT_TYPES (apps/api/src/domain/artifacts/artifact-projection-events.ts) is an allow list, not a deny list: a new artifact type is born outside the projection and enters when someone decides it is a document — instead of appearing on its own in a folder the user reads.

Left out are qa_verdict, secops_verdict, task_blocked and infra_delegation_files. They are operational outcomes, not product documents: a gate verdict per task, in a project with dozens of tasks, would fill docs/qa-lead/ with files nobody opens, and the folder would lose the property that makes it useful — fitting in one glance. They remain in the event log, which is the source, and remain in the timeline.

4. Per agent, and the versioned one overwrites​

The layout is docs/<agente>/<arquivo>.md. Browsing the folder answers "what did the Arquiteto produce?" without opening anything.

The four versioned types (module_map, module_routing, project_image, c4_diagram — the ones read by reduction to the highest version, see obter-container-do-projeto.use-case.ts:22-25) always write the same file, overwriting: the folder shows the CURRENT one, and the history remains whole in the event log. Projecting them as a file per version would produce c4-diagram-v1.md, c4-diagram-v2.md and would make whoever opens it have to figure out which one holds — exactly the work the reduction by version exists to spare.

The others are append-only by nature (a decision does not replace the previous one: both happened) and each becomes its own file, with the event's seq in the name. The seq is gapless per session and already orders the event log, so it is the tie-break that requires no new state — and without it two artifacts of the same type with the same title would generate the same file, and the second would erase the first: the only way this projection could lose information the source has.

5. Two barriers against the filesystem, and the second is not redundant​

The file name is derived from the title the model wrote — untrusted input in every sense of the term. The defense is not escaping what came, it is deriving a new name from a closed alphabet: slugDeArquivo has no way back in which a separator survives, and returns null when nothing is left (an empty name would collide with all other empty names).

FsArtifactFileStore checks again, with relative(), before opening the file. It is not paranoia: the two answer different questions — the first is "what name do I derive from this", the second is "what am I actually going to open". Trusting only the first would make the security of this write depend on no future slug change ever letting a separator through, which is precisely the form of containment ADR 0130 rejects on principle: the one that depends on the caller being correct.

6. In runner mode, the folder detours to the managed root​

pastaDeArtefatosDoProjeto (apps/api/src/infrastructure/filesystem/project-workspaces-root.ts) is a sibling of permissionsFilePath and makes the same detour, for the same physical reason: whoever writes is the api, from inside its container, and a runner project is deliberately without a bind-mount. The runner's channel (exec/exec_result) only carries COMMANDS — there is no primitive to write content to a file —, so not even delegating the write would solve it without inventing a new mechanism.

7. The write does NOT go through proposed_action​

And it is not an omission. proposed_action is the origin of every external effect of an agent asking to act. Here there is no agent asking for anything: the artifact has already been emitted and is already in the event log when this class runs. What happens afterwards is the system materializing, in a derived path, a readable copy of something that is already fact.

Making the projection go through the approval queue would have the opposite effect of the one intended: it would fill the queue with routine decisions — one per artifact — and would erode precisely the ceiling that gives meaning to the click on the actions that are external effects.

Consequences​

The user now has, in the project folder, a docs/ folder organized by agent with the reasoning that produced the code. It appears before git exists (the deferred provisioning), which is precisely the interval in which today there is nothing on disk.

The projection never brings down the source. No throw escapes drainOnce: each item fails in isolation, is logged and stays processed_at IS NULL for the next cycle. A full disk, denied permission or unreachable folder cannot prevent an artifact from being EMITTED. outbox_events has neither attempts nor a dead-letter — the retry is implicit and infinite, exactly like the graph's.

A deliberate difference relative to GraphProjector: there is no equivalent here to GraphUnavailableError that stops the whole batch. A write failure is usually the ITEM's (a strange name, a project whose folder disappeared), and not the destination's; stopping the batch because of one item would block all those behind it.

An event that vanished from the event log is marked processed, not retried forever — retrying what no longer exists would have no effect at all, and the row would stay in the queue indefinitely.

Declared cost 1: in runner mode, docs/ does not sit next to the code. Whoever opens the project folder on their own machine will not find it; it is in <PROJECT_WORKSPACES_ROOT>/<workspace_dir_name>/docs/. It is a REAL loss in this mode, because the folder exists precisely to be opened together with the code. What it avoids is the worse alternative: the projection simply not happening, or failing silently on every artifact.

Declared cost 2: the Markdown is generic. The file has a header (type, agent, when, seq) and the payload as a JSON block. There is no per-type renderer — it would be thirteen places to age when a schema changed, and what this folder needs to deliver is readable and traceable content, not layout. A richer per-type format is its own decision, when there is a real reading asking for it.

Declared cost 3: the folder can diverge from the event log between one cycle and another, and after a persistent failure it can remain incomplete indefinitely without anything warning. It is the same cost the graph already accepts, and it is tolerable for the same reason: the source is intact and the projection is rebuildable. There is no reprojection command today — deleting docs/ does not rebuild it, because the outbox rows are already marked. It is the same gap BRB-018 already records for the graph, now with a second consumer.

What this ADR does NOT touch. decide.ts and the absolute ceilings; proposed_action as the origin of every external agent effect (see point 7 — the projection is not one of them); the immutability of the event log; the engine's artifact schemas, which do not change at all; and the image gate (ADR 0135).