Files
stack/agents/darkwing/work/slice1-data-model-2026-10-04.md
T
jason.woltjeandClaude Opus 5.5 bb7e37dda2 docs(plans): slice 1 data model note and prototype; lead decision 46
Darkwing's design note and SQLite prototype as records. Sage accepts
seven of eight open questions; the PM launching sessions goes to Jason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 14:19:14 -05:00

500 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Slice 1 data model (design note, 2026-10-04)
Darkwing, for Sage. Input to the slice 1 brief. Sources:
`docs/plans/2026-10-04_foundation-direction.md`, lead decisions 43 and 44,
`docs/plans/2026-10-04_project-relaunch-roles.md`, and the code named
below. Design only. Nothing here is built or committed.
Researcher's Vikunja and Pocket ID report
(`agents/researcher/work/2026-10-04_vikunja-pocketid.md`) had not landed
when I wrote this. Section 5 marks the Vikunja facts I took from memory
and haven't checked.
The prototype in `agents/darkwing/work/slice1-proto/` backs section 3.
`schema.sql` is the DDL. `proto.mjs` exercises the triggers, and
`replace.mjs` shows the REPLACE hole described in 3.3. Both runs (Node
24.21.0 in the `node:24` image, and Node 26.8.1 on the host) printed the
same results, saved beside them.
## Summary
- A role is a reviewed file in `roles/`, schema version 2. It keeps
version 1's `tools` and `network` ceilings, so `agent.sh` and
`resolve-role` keep working once the validator accepts version 2. It
adds a contract, an authority map and credential needs.
- Any action with an outside effect is gated unless the role lists it.
Classes are enforced in three places: the credential's scope (the hard
line), `mosaic` CLI verbs, and harness hooks (logging and early stops).
- A business file outside the repository names the role instances, the
arbiters, the credential references and the business variables. A
project file in the project repository holds project variables.
`config.json` doesn't change.
- One SQLite file, `<dataRoot>/bus/bus.sqlite`, holds role claims,
decisions, messages, deliveries and events. Every table is
append-only. A trigger enforces one holder per role.
- Vikunja owns what a person sees and edits on a task. The queue keeps
the agents' lock, review rounds and audit log. No field is synced in
both directions.
## 1. Roles
### 1.1 What exists
- `roles/researcher.json` is `{roleVersion: 1, name, tools, network}`.
`validateRole` in `scripts/mosaic-task.mjs` (lines 252–273) refuses
unknown keys, any version other than 1, and a name that doesn't match
the filename.
- `scripts/agent.sh` (lines 149–165) reads `role` from
`agents/<seat>/agent.json`, runs `mosaic-task.mjs resolve-role
roles/<role>.json`, and intersects the seat's tools with the role's
ceiling. Only `agent.sh` and `scripts/test-task.sh` call
`resolve-role`.
- `roles/conductor-policy.json` is a different kind of file
(`policyVersion`, the conductor's auto-apply paths and suites).
`resolve-role` refuses it, and a test checks that.
### 1.2 Role definition, `roles/<name>.json`, version 2
A definition says what a role is and may do. It isn't tied to a business
or a person.
```json
{
"roleVersion": 2,
"name": "coder",
"title": "Coder",
"contract": "roles/coder.md",
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"],
"crossRole": ["task.reassign", "task.scope.change"]
},
"credentials": [
{ "service": "gitea", "scopes": ["write:repository", "write:issue"] },
{ "service": "vikunja", "scopes": ["tasks.read", "tasks.update"] }
]
}
```
Rules:
- `contract` names a Markdown file under `roles/`: the role's duties and
protocol, in prose. The generated system prompt is `contracts/` plus
this file plus the resolved non-secret variables. Version 2 requires
the file to exist inside `roles/`.
- `authority` uses a closed action vocabulary, a reviewed list in code
(section 1.4). A role file that names an unknown action is refused.
- Any vocabulary action the role doesn't list is gated. A role can't list
a gated-only action (`credential.mint`, `git.merge.protected`,
`git.push.protected`, `deploy`, `spend`, `message.external`,
`policy.change`, `prd.approve`); the validator refuses it.
- Routine work (editing files, running tests, writing docs, retrying) has
no outside effect, so it isn't in the vocabulary and needs no entry.
- `credentials` lists services and scopes, never a value or a path. The
business file binds each to a reference (section 2).
- There's no holder count. One instance has one holder (1.3). Two coders
are two instances of the same definition.
The four definitions slice 1 needs (decision 43):
| Role | withinRole | crossRole | Contract in short |
|---|---|---|---|
| `pm` | `task.create`, `task.assign`, `task.schedule`, `task.close`, `role.launch` (if Jason allows it, see 6.6), `message.send` | `task.priority.change` against the CTO's call | Breaks requirements into tasks; every task cites a PRD requirement; arbiter for delivery order |
| `cto` | `review.request`, `task.update.assigned`, `message.send`, `decision.resolve.technical` | `task.scope.change` | Technical direction; arbiter for technical conflicts |
| `coder` | as in the example | as in the example | Does assigned tasks; pushes only to the working branch |
| `reviewer` | `review.verdict`, `message.send`, `task.update.assigned` | none | Reviews candidates; never reviews its own work |
CEO, CFO and the other roles can be declared with the same schema. None
is instantiated until a business needs one (decision 43, item 4).
### 1.3 One holder per role instance
A session that takes a role writes a `claim` row. A trigger refuses a
second claim while the last row for that (business, role) is a claim.
Only the holder's own run may `release`. Anyone else must `revoke`, and
a revoke has to cite a resolved decision. That matches the queue's rule
that nothing is removed because of its age, and only `unlock` removes
another process's lock. All of this ran in the prototype
(`role already held`, `only the holder releases; others revoke`,
`revoke needs a resolved decision`).
The claim records:
- `holder_run`, a new id minted at claim time. The relaunch doc's
constraint 5 says identity is the role plus the run.
- `harness` (`t3`, `pi`, `claude`).
- `address`, the transport address messages go to: the T3 thread id
today.
A T3 thread id survives a restart, but the claim isn't tied to it. A
restarted session has to claim again, or find its own live claim by
`holder_run`.
Why the claims live in SQLite and not in a file lock like the queue's:
delivery resolves "role → holder → address" when it delivers a message.
With claims and messages in one database, a single transaction reads
both, and the router can't route to a holder that has just released. A
file lock beside the database would allow that split. This goes slightly
past decision 44, which names decisions and messages (see 6.2).
### 1.4 Decision classes as data a hook can enforce
The action vocabulary lives in code (a frozen list in the new package)
and covers every action with an outside effect that slice 1 touches:
`task.create`, `task.assign`, `task.schedule`, `task.update.assigned`,
`task.close`, `task.reassign`, `task.scope.change`,
`task.priority.change`, `git.push.working`, `git.push.protected`,
`git.merge.protected`, `review.request`, `review.verdict`,
`message.send`, `message.external`, `role.launch`, `role.revoke`,
`credential.mint`, `spend`, `deploy`, `policy.change`, `prd.approve`,
`decision.resolve.technical`.
The class of an action for a given role:
- listed under `withinRole`: within-role. The holder does it and an
event is logged;
- listed under `crossRole`: raises a decision routed to the business's
arbiter for that domain;
- anything else: gated. It raises a decision routed to the human.
Where each class is actually enforced:
1. **Credential scope, the hard line.** A role that may not merge to
`main` holds no token that can. The Gitea token for `coder` has no
admin or branch-protection rights. Protected branches are set on the
server, and the Vikunja token is scoped. A hook can be bypassed, a
missing permission can't.
2. **`mosaic` verbs.** Every verb that performs a vocabulary action looks
up the caller's role and class. A gated verb refuses unless it's given
a resolved decision id whose `action` matches.
3. **Harness hooks.** A Claude Code PreToolUse hook, or a Pi tool-call
hook, classifies known commands (`git push`, `git merge`, `tea`,
`curl -X POST` to the Gitea API, `docker push`, `npm publish`) and
stops a gated one early with a pointer to the decision verb. This is
a deny-list over shell text, the same weakness I raised as B1 on row
5. It logs and catches mistakes. It is not the boundary, and the brief
shouldn't call it one.
A Pi hook is an extension, and the CHAT-03 seal (decision 31) refuses
extensions. The meta-harness work will need a pinned exception checked
by digest. That belongs in that brief, not here.
### 1.5 Fit with existing role resolution
It fits with one validator change. `validateRole` accepts version 2,
checks the new keys, and `resolve-role` prints the same
`MOSAIC_ROLE_TOOLS` and `MOSAIC_ROLE_NETWORK` lines plus
`MOSAIC_ROLE_CONTRACT`. Version 1 files stay valid. `researcher.json`
can stay at version 1; it's a worker ceiling with no authority.
`conductor-policy.json` stays separate in slice 1. Its auto-apply rule is
the conductor's within-role authority in another shape. Folding it into a
role file would change a fail-closed policy path for no slice 1 gain.
## 2. Variables in layers
| Layer | Where | Written by | Holds |
|---|---|---|---|
| System | `~/.config/mosaic-dev/config.json` | the user; created only by `bootstrap.sh` | `environment`, `dataRoot`, `execution.*` (unchanged) |
| Business | `~/.config/mosaic-dev/businesses/<business>.json` | the user | role instances, arbiters, credential references, business variables |
| Project | `<project root>/.mosaic/project.json` | reviewed commits in that project | working branch, protected branches, suites, tracker project id, issue repo |
| Agent | `roles.<instance>.vars` in the business file | the user | per-instance values: model, thinking level, harness |
Slice 1 adds no key to `config.json`, so invariant 2 holds as written.
The resolver finds business files at a fixed path next to the config
file and never creates one. A missing or invalid file refuses, as a bad
`config.json` does today. Whether a business file in the config
directory reads as a second "system config" is open question 6.1.
The project file goes in a `.mosaic/` directory because Mosaic Stack
will manage projects in other people's repositories. A dot-directory is
the least intrusive convention there, and in this repository it adds a
directory, not a root file.
Business file sketch (no secret values; paths are references):
```json
{
"businessVersion": 1,
"id": "mosaic-stack",
"human": "jason",
"arbiters": { "delivery": "pm", "technical": "cto" },
"projects": { "stack": { "root": "/mnt/storage/src/mosaic-stack" } },
"vars": { "tracker.kind": "vikunja", "tracker.baseUrl": "http://127.0.0.1:3456" },
"roles": {
"pm": { "definition": "pm", "vars": { "harness": "t3" },
"credentials": { "gitea": { "file": "/abs/path/pm-gitea.token" },
"vikunja": { "file": "/abs/path/pm-vikunja.token" } } },
"coder": { "definition": "coder", "vars": { "harness": "pi" },
"credentials": { "gitea": { "file": "/abs/path/coder-gitea.token" },
"vikunja": { "env": "MOSAIC_VIKUNJA_TOKEN_CODER" } } }
}
}
```
Precedence and merge:
- Each variable key is declared once in code with a type, the layers
allowed to set it, and a merge rule. An unknown key refuses, and so
does a key set at a layer it isn't allowed in.
- Plain values: the most specific layer wins, agent over project over
business over system.
- Limits (`tools`, `network`, authority): layers intersect and only
narrow, the same rule missions and tasks follow today (invariant 7).
A project can drop `git.push.working` from the coder. It can't add
`deploy`.
- System keys (`dataRoot`, `environment`, `execution.backend`) are
system-only.
Secrets:
- A credential reference is `{file: <absolute path>}` or
`{env: <NAME>}`. The resolver checks a file with `stat` (owner, mode
0600, regular file, outside the repository and `dataRoot`) and never
reads it. The launcher hands it to the agent as a read-only mount or an
environment variable (invariant 3).
- There is no `secret` variable type. A value that has to stay secret
goes in `credentials`.
- The resolved variables, their provenance (which layer set each key)
and a digest go into the `session.launched` event. Credential
references appear there by service name only.
Role tokens are new tokens, one per role instance. Minting them is gated
(decision 44 puts minting after slice 1), so Jason mints the slice 1 set
by hand. They don't go under `~/.mosaic`.
## 3. SQLite for decisions and messages
### 3.1 Place and access
- File: `<dataRoot>/bus/bus.sqlite`, with its `-wal` and `-shm`. A new
`bus/` entry in the data map, owned by one new package
(`packages/bus`), the same per-directory ownership rule as `runs/` and
`sessions/`. Nothing else opens it for writing.
- It's never pruned. Evidence copies use `VACUUM INTO`.
- Settings: WAL, `foreign_keys=ON`, `busy_timeout` through the
constructor's `timeout` option (as `packages/ledger/src/t3.mjs` does),
and writes in `BEGIN IMMEDIATE`. `node:sqlite` also accepts
`defensive: true`, which I'd set.
- The file sits on the host's local disk, never on NFS. Containers never
mount it.
- On open, the package checks `meta.schema_version` and compares every
trigger's SQL in `sqlite_master` with the expected text. A mismatch
refuses, which is how a dropped trigger is caught.
### 3.2 node:sqlite on our Node versions
There is no exact Node pin. The host runs v26.8.1. The `Containerfile`
uses the floating tag `node:24-bookworm-slim`, and the local
`mosaic-poc-agent:0.85.1-r0.0.12` image runs v24.21.0. On both versions
`require("node:sqlite")` loads with no flag and printed no warning.
`DatabaseSync` works, the bundled SQLite is 3.53.4, and STRICT tables,
JSON functions and triggers behave as the prototype expects.
`packages/ledger` already uses it read-only. I didn't check the stability
index the Node docs give the module on each version. The database is
host-side only, so the container's Node matters only if that ever
changes.
### 3.3 Tables
All tables are STRICT. `seq` is the order, `id` is a random UUID, and
`at` is a UTC ISO timestamp. Full DDL is in `slice1-proto/schema.sql`.
- `role_claims`: seq, at, business, role, op (`claim`, `release`,
`revoke`), holder_run, harness, address, by, reason, decision.
- `decisions`: id, at, business, project, raised_by_role, raised_by_run,
class, action, route_to (an arbiter role or `human`), question,
options (a JSON array of 2 to 9 `{key, text}`), recommendation,
task_ref, requirement_ref, supersedes.
- `decision_events`: decision, at, op (`seen`, `resolved`, `withdrawn`,
`expired`), by, choice, note, via (`cli`, `webui`, `discord`).
Triggers refuse a second closing row and a `resolved` row whose
choice isn't one of the option keys.
- `messages`: id, at, business, from_role, from_run, to_role (a role
instance or `human`), class (the header classes we use now: REQUEST,
ASSIGNMENT, REVIEW-RESULT and so on), in_reply_to, decision, corrects,
body.
- `deliveries`: message, at, op (`routed`, `delivered`, `failed`,
`read`), holder_run, transport (`t3`, `discord`, `cli`), address,
detail.
- `events`: id, at, business, kind, actor_role, actor_run, subject,
corrects, body (JSON). Section 4.
- `meta`: schema version and the expected trigger digest.
Decision lifecycle:
- Routine choices aren't recorded as decisions.
- A within-role decision is raised and resolved by the holder in one
transaction. That's the "logged" in the direction doc's table, and it
gives the measures a count of what agents settled alone.
- A cross-role decision goes to the arbiter's inbox, a gated one to the
human's. The inbox shows decisions with no closing row, gated first,
then oldest first.
- A changed question is a new row with `supersedes`, and the old one is
closed `withdrawn`.
Messages are addressed to a role, never a thread. The router reads the
latest claim for `to_role` and writes `routed`. The transport then
writes `delivered` or `failed`. A message to an unheld role stays
`routed` with no holder and is delivered when the role is next claimed.
T3 and Discord are transports. A correction is a new message with
`corrects`.
### 3.4 Append-only enforcement
Each table has BEFORE UPDATE and BEFORE DELETE triggers that raise
`<table> is append-only`. That alone isn't enough. The prototype showed
that `INSERT OR REPLACE` with an existing id quietly overwrote a
decision. SQLite's REPLACE deletes the conflicting row without firing
delete triggers unless `recursive_triggers` is on for that connection
(`replace.mjs`). The fix that works on every connection is a BEFORE
INSERT trigger per table that refuses an existing `seq` or `id`. With it,
both REPLACE and UPSERT are refused.
The triggers catch bugs in our own code. They don't stop a same-user
process from dropping a trigger. The open-time check catches that
afterwards, and a hash chain over rows would make tampering evident (6.4).
### 3.5 Who may write what
The database can't tell who is calling. Today's development seats run in
T3 as Jason's user with full access, so any of them could insert a
`resolved` row signed `jason`. Section 6.3 covers this. It's the main
open boundary in the slice.
## 4. Events for later measures
Slice 1 has to emit these. Rows in the dedicated tables count as their
events, and a view joins everything for measures.
| Kind | When | Body |
|---|---|---|
| `role.claimed`, `role.released`, `role.revoked` | `role_claims` rows | holder_run, harness |
| `session.launched` | the stack starts an agent | role, harness, model, vars digest and provenance, credential service names, contract digest |
| `session.ended` | it stops | exit code, reason |
| `message.sent`, `.routed`, `.delivered`, `.failed`, `.read` | `messages` and `deliveries` rows | class, to_role, transport |
| `decision.raised`, `.seen`, `.resolved`, `.withdrawn` | `decisions` and `decision_events` rows | class, action, route_to, choice, time open |
| `action.allowed` | a within-role action is done | action, target |
| `action.refused` | a verb or hook refuses | action, class, layer that refused (credential, verb, hook) |
| `task.created`, `.assigned`, `.state`, `.closed` | the adapter writes to the tracker | tracker ref, requirement ref, role |
| `task.changed.external` | a person changed a task in Vikunja | tracker ref, fields |
| `task.conflict` | the tracker and the queue disagree | both values |
| `review.requested`, `review.verdict` | mirrored from the queue log | row, round, verdict |
| `human.input` | Jason does anything through `mosaic` | kind: `answer` (a decision), `instruction` (a message to a role), `admin` (launch, relaunch, credential, status) |
| `config.refused` | a layer or role file fails validation | file, reason code |
`human.input` is the event that answers Jason's "reduced in what way?".
Answers are decisions, while instructions and admin are what slice 1
should push toward zero, with no hand tagging. The CLI knows which verb
ran, so it writes the kind. Talking to a role in free text counts as
`instruction`.
## 5. Tasks and Vikunja
Things I took from memory and haven't checked; Researcher's report
should confirm or correct them:
- Vikunja's REST API is under `/api/v1`.
- It has API tokens with permissions per route group, and webhooks per
project.
- Tasks have assignees (users), labels, a due date, a priority, a `done`
flag and kanban buckets.
- A task's `updated` timestamp is the only concurrency signal; I don't
know of ETag or If-Match support.
### 5.1 Interface
```
interface TaskTracker {
create({ project, title, description, requirementRef, assigneeRole, due?, labels? }) -> { ref, url }
get(ref) -> Task
list({ project, assigneeRole?, state?, updatedSince? }) -> Task[]
update(ref, patch, { expectUpdated }) -> Task // refuses if the task changed since expectUpdated
assign(ref, role, { expectUpdated }) -> Task
transition(ref, state, { expectUpdated }) -> Task // todo | in-progress | in-review | blocked | done
comment(ref, text) -> { id }
changesSince(cursor) -> { changes[], cursor } // poll in slice 1; webhooks later
}
```
- `create` refuses without a `requirementRef`. That's the
PRD → goal → task chain check from the direction doc, and it fails
closed.
- Each role instance is one Vikunja user with its own scoped token. A
change of holder doesn't change the Vikunja user.
- States map to one kanban bucket each in the project, and `done` also
sets Vikunja's `done` flag.
- `expectUpdated` is a compare-then-write. Without server support there's
a race window. One holder per role and one assignee per task keep it
small. The SetSpark clashes (a takeover 15 seconds after a move, two
seats editing one description) came from neither existing.
### 5.2 What is the source of truth for what
| Data | Owner | The other side |
|---|---|---|
| Task title, description, assignee, dates, priority, labels | Vikunja | the queue stores the tracker ref only |
| Task state todo, in-progress, blocked, done | Vikunja | the queue mirrors it on claim and close |
| Claim lock, review rounds, candidate digest, verdicts, the audit log | the queue | the adapter writes `in-review` to Vikunja, one way |
| Decisions and messages | `bus.sqlite` | a Vikunja comment links to a decision, nothing more |
- A queue row gets a `tracker` field (`vikunja:<project>/<id>`) when an
agent claims the task with `queue move ... in-progress`. Tasks no
agent has claimed don't need a row.
- No field is written from both sides. If Jason moves a card to done in
Vikunja while the queue says `in-review`, the adapter logs
`task.conflict` and raises a cross-role decision to the PM. It doesn't
overwrite either side.
- The connection settings are business variables (`tracker.kind`,
`tracker.baseUrl`) plus a per-role credential reference. That covers
decision 44's installer choice of an existing Vikunja or the bundled
one.
## 6. Open questions, each with a recommendation
6.1. **Business files in the config directory.** Is
`~/.config/mosaic-dev/businesses/<id>.json` a second system config under
invariant 2? Recommendation: no. It's a different layer, user-authored,
never written by the stack, fail-closed, and `config.json` doesn't
change. Sage records that reading as a lead decision. If Sage reads it as
an invariant change, it goes to Jason.
6.2. **Role claims and events in `bus.sqlite`.** Decision 44 names
decisions and messages. Recommendation: put claims and events in the
same file, for the routing reason in 1.3 and because measures read
across all three. It's the same append-only rule, so I'd treat it as
within the decision. Sage confirms.
6.3. **Who can write a human resolution.** Same-user agents with full
access can write any row. Recommendation:
- Slice 1 agents that the stack launches never open the database. They
reach `packages/bus` through a broker socket that stamps role and run
from the launch record, not from anything the agent says.
- Human resolution happens only in a `mosaic` CLI session started
outside any agent run.
- Today's T3 seats are advisory until they run under another user or
in a container.
- The brief should say this plainly and not claim a boundary the slice
doesn't have. Pocket ID closes it for the WebUI after slice 1.
6.4. **Tamper evidence.** Recommendation: slice 1 ships triggers plus
the open-time schema check. A `prev_hash` column chained over each table
waits until a measure depends on it.
6.5. **The coder role and the worker invariant.** Workers have no git
and no credentials. A coder that pushes to the working branch needs
both. Recommendation: role agents are a new kind, interactive seats
launched with a role binding the way `agent.sh` launches seats now.
Workers keep their invariant. The coder's sandbox is a later piece and
shouldn't be borrowed from the worker one.
6.6. **The PM launching sessions.** `role.launch` changes who starts
work, which is Jason today. The relaunch doc's constraint 4 says that
needs his ruling. Recommendation: the PM's file lists `role.launch` as
within-role only after Jason rules. Until then it stays gated, so each
launch is a one-word decision in his inbox.
6.7. **Revoking a stuck role.** T3 gives no reliable liveness signal for
a holder. Recommendation: a revoke is gated in slice 1 and always cites
a resolved decision (the trigger already requires that). Automatic
revocation waits for a liveness signal we trust.
6.8. **Vikunja concurrency.** Recommendation: if Researcher confirms
there's no conditional update, keep compare-then-write. Rule that only
the assigned role writes a task's mutable fields, and a person's edits
come in as `task.changed.external` events.