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]>
25 KiB
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'stoolsandnetworkceilings, soagent.shandresolve-rolekeep 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),
mosaicCLI 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.jsondoesn'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.jsonis{roleVersion: 1, name, tools, network}.validateRoleinscripts/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) readsrolefromagents/<seat>/agent.json, runsmosaic-task.mjs resolve-role roles/<role>.json, and intersects the seat's tools with the role's ceiling. Onlyagent.shandscripts/test-task.shcallresolve-role.roles/conductor-policy.jsonis a different kind of file (policyVersion, the conductor's auto-apply paths and suites).resolve-rolerefuses 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:
contractnames a Markdown file underroles/: the role's duties and protocol, in prose. The generated system prompt iscontracts/plus this file plus the resolved non-secret variables. Version 2 requires the file to exist insideroles/.authorityuses 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.
credentialslists 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:
- Credential scope, the hard line. A role that may not merge to
mainholds no token that can. The Gitea token forcoderhas 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. mosaicverbs. 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 whoseactionmatches.- Harness hooks. A Claude Code PreToolUse hook, or a Pi tool-call
hook, classifies known commands (
git push,git merge,tea,curl -X POSTto 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 dropgit.push.workingfrom the coder. It can't adddeploy. - 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 withstat(owner, mode 0600, regular file, outside the repository anddataRoot) 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
secretvariable type. A value that has to stay secret goes incredentials. - The resolved variables, their provenance (which layer set each key)
and a digest go into the
session.launchedevent. 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-waland-shm. A newbus/entry in the data map, owned by one new package (packages/bus), the same per-directory ownership rule asruns/andsessions/. Nothing else opens it for writing. - It's never pruned. Evidence copies use
VACUUM INTO. - Settings: WAL,
foreign_keys=ON,busy_timeoutthrough the constructor'stimeoutoption (aspackages/ledger/src/t3.mjsdoes), and writes inBEGIN IMMEDIATE.node:sqlitealso acceptsdefensive: 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_versionand compares every trigger's SQL insqlite_masterwith 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 orhuman), 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 aresolvedrow whose choice isn't one of the option keys.messages: id, at, business, from_role, from_run, to_role (a role instance orhuman), 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 closedwithdrawn.
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
doneflag and kanban buckets. - A task's
updatedtimestamp 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
}
createrefuses without arequirementRef. 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
donealso sets Vikunja'sdoneflag. expectUpdatedis 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
trackerfield (vikunja:<project>/<id>) when an agent claims the task withqueue 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 logstask.conflictand 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/busthrough a broker socket that stamps role and run from the launch record, not from anything the agent says. - Human resolution happens only in a
mosaicCLI 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.