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

25 KiB
Raw Blame History

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.

{
  "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):

{
  "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.