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]>
This commit is contained in:
2026-10-04 14:19:14 -05:00
co-authored by Claude Opus 5.5
parent 9c69f2fba3
commit bb7e37dda2
8 changed files with 727 additions and 0 deletions
@@ -0,0 +1,499 @@
# 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.
@@ -0,0 +1,18 @@
ok claim pm by run A
refuse claim pm by run B -> role already held
refuse release pm by run B -> only the holder releases; others revoke
refuse revoke pm without decision -> revoke needs a resolved decision
ok release pm by run A
ok claim pm by run B after release
ok raise gated decision
refuse raise with one option -> CHECK constraint failed: json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9
refuse resolve with choice Z -> resolution must name one of the options
ok resolve with choice B
refuse resolve again -> decision already closed
refuse UPDATE decisions -> decisions is append-only
refuse DELETE decision_events -> decision_events is append-only
refuse DELETE role_claims -> role_claims is append-only
refuse INSERT OR REPLACE decisions -> decisions is append-only
refuse UPSERT decisions -> decisions is append-only
rows: 1 rec: B
journal: wal | triggers: 27
@@ -0,0 +1,18 @@
ok claim pm by run A
refuse claim pm by run B -> role already held
refuse release pm by run B -> only the holder releases; others revoke
refuse revoke pm without decision -> revoke needs a resolved decision
ok release pm by run A
ok claim pm by run B after release
ok raise gated decision
refuse raise with one option -> CHECK constraint failed: json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9
refuse resolve with choice Z -> resolution must name one of the options
ok resolve with choice B
refuse resolve again -> decision already closed
refuse UPDATE decisions -> decisions is append-only
refuse DELETE decision_events -> decision_events is append-only
refuse DELETE role_claims -> role_claims is append-only
refuse INSERT OR REPLACE decisions -> decisions is append-only
refuse UPSERT decisions -> decisions is append-only
rows: 1 rec: B
journal: wal | triggers: 27
@@ -0,0 +1,30 @@
import { DatabaseSync } from "node:sqlite";
import { readFileSync, mkdtempSync } from "node:fs";
import { join } from "node:path"; import { tmpdir } from "node:os";
const f = join(mkdtempSync(join(tmpdir(), "s1-")), "bus.sqlite");
const db = new DatabaseSync(f, { timeout: 5000 });
db.exec(readFileSync(new URL("./schema.sql", import.meta.url), "utf8"));
const now = () => new Date().toISOString();
const tryit = (label, fn) => { try { fn(); console.log("ok ", label); } catch (e) { console.log("refuse", label, "->", e.message); } };
const claim = db.prepare("INSERT INTO role_claims (at,business,role,op,holder_run,harness,address,by,reason,decision) VALUES (?,?,?,?,?,?,?,?,?,?)");
tryit("claim pm by run A", () => claim.run(now(),"mosaic-stack","pm","claim","run-A","t3","thread-1","run-A",null,null));
tryit("claim pm by run B", () => claim.run(now(),"mosaic-stack","pm","claim","run-B","t3","thread-2","run-B",null,null));
tryit("release pm by run B", () => claim.run(now(),"mosaic-stack","pm","release","run-B","t3",null,"run-B",null,null));
tryit("revoke pm without decision", () => claim.run(now(),"mosaic-stack","pm","revoke","run-A","t3",null,"jason","stuck",null));
tryit("release pm by run A", () => claim.run(now(),"mosaic-stack","pm","release","run-A","t3",null,"run-A",null,null));
tryit("claim pm by run B after release", () => claim.run(now(),"mosaic-stack","pm","claim","run-B","t3","thread-2","run-B",null,null));
const dec = db.prepare("INSERT INTO decisions (id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation) VALUES (?,?,?,?,?,?,?,?,?,?,?)");
const opts = JSON.stringify([{key:"A",text:"push"},{key:"B",text:"hold"}]);
tryit("raise gated decision", () => dec.run("d-1",now(),"mosaic-stack","coder","run-C","gated","git.push:next","human","Push to next?",opts,"B"));
tryit("raise with one option", () => dec.run("d-2",now(),"mosaic-stack","coder","run-C","gated","x","human","?",JSON.stringify([{key:"A"}]),"A"));
const ev = db.prepare("INSERT INTO decision_events (decision,at,op,by,choice,note,via) VALUES (?,?,?,?,?,?,?)");
tryit("resolve with choice Z", () => ev.run("d-1",now(),"resolved","jason","Z",null,"cli"));
tryit("resolve with choice B", () => ev.run("d-1",now(),"resolved","jason","B",null,"cli"));
tryit("resolve again", () => ev.run("d-1",now(),"resolved","jason","A",null,"cli"));
tryit("UPDATE decisions", () => db.exec("UPDATE decisions SET recommendation='A' WHERE id='d-1'"));
tryit("DELETE decision_events", () => db.exec("DELETE FROM decision_events"));
tryit("DELETE role_claims", () => db.exec("DELETE FROM role_claims"));
tryit("INSERT OR REPLACE decisions", () => db.exec(`INSERT OR REPLACE INTO decisions (id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation) VALUES ('d-1','x','b','r','r','gated','a','human','q','${opts}','A')`));
tryit("UPSERT decisions", () => db.exec(`INSERT INTO decisions (id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation) VALUES ('d-1','x','b','r','r','gated','a','human','q','${opts}','A') ON CONFLICT(id) DO UPDATE SET recommendation='A'`));
console.log("rows:", db.prepare("SELECT count(*) n FROM decisions").get().n, "rec:", db.prepare("SELECT recommendation r FROM decisions WHERE id='d-1'").get().r);
console.log("journal:", db.prepare("PRAGMA journal_mode").get().journal_mode, "| triggers:", db.prepare("SELECT count(*) n FROM sqlite_master WHERE type='trigger'").get().n);
@@ -0,0 +1,16 @@
import { DatabaseSync } from "node:sqlite";
const opts = `'[{"key":"A"},{"key":"B"}]'`;
const base = `CREATE TABLE decisions (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, rec TEXT NOT NULL) STRICT;
CREATE TRIGGER d_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT,'append-only'); END;
CREATE TRIGGER d_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT,'append-only'); END;
INSERT INTO decisions (id,rec) VALUES ('d-1','B');`;
const guard = `CREATE TRIGGER d_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT,'append-only: key exists'); END;`;
for (const [label, extra, pragma] of [["triggers only", "", false], ["recursive_triggers on", "", true], ["insert guard", guard, false]]) {
const db = new DatabaseSync(":memory:");
if (pragma) db.exec("PRAGMA recursive_triggers = ON");
db.exec(base + extra);
for (const sql of ["INSERT OR REPLACE INTO decisions (id,rec) VALUES ('d-1','A')", "REPLACE INTO decisions (seq,id,rec) VALUES (1,'d-x','A')"]) {
try { db.exec(sql); console.log(label, "| ALLOWED:", sql.slice(0, 30), "->", JSON.stringify(db.prepare("SELECT seq,id,rec FROM decisions").all())); }
catch (e) { console.log(label, "| refused:", sql.slice(0, 30), "->", e.message); }
}
}
@@ -0,0 +1,108 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL,
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT, requirement_ref TEXT,
supersedes TEXT REFERENCES decisions(id)
) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
+1
View File
@@ -468,3 +468,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-04T19:01:37Z | Sage (T3 Claude Code, thread 1ef1e4f8) | foundation ratified, row 32 parked, row 5 r1 records | Jason ratified the foundation direction (lead decision 44; goals page superseded in part); row 32 parked on Jason's word (rev 55, 3270cc4e); row 5 r1 closed with both reviewers requesting changes (darkwing 26681, filbert 26683), both review files committed; pointer comment 26685 on #1507 for darkwing's verdict that landed on #1508; DEFERRED item for review record not checking the comment's issue
2026-10-04T19:12:27Z | Filbert (T3 Claude Code, thread 9cb9731e) | meta-harness survey started (slice 1 step 8, Sage ASSIGNMENT) | research only in agents/filbert/work/; no commits; row 5 round 2 takes priority
2026-10-04T19:12:38Z | Sage (T3 Claude Code, thread 1ef1e4f8) | PRDY round 1, seat launches (decision 45) | PRD draft 0.1 at docs/prd/mosaic-stack.md; launched Researcher (T3 Sonnet 5.5, thread 9bac0794) for the Vikunja and Pocket ID report; assigned Darkwing the slice 1 data model note and Filbert the meta-harness survey (both T3 Opus 5.5, existing threads); Dewey on the row 5 rework; running under the new limits: Opus 3 of 4 (dewey, darkwing, filbert), Sonnet 1 of 4 (researcher)
2026-10-04T19:19:14Z | Sage (T3 Claude Code, thread 1ef1e4f8) | slice 1 data model received | Darkwing's note (948b94ce) and prototype committed as records; lead decision 46 accepts 6.1-6.5, 6.7, 6.8 and sends 6.6 (PM launching sessions) to Jason in PRDY round 3
+37
View File
@@ -692,3 +692,40 @@ which stay with him. Each item names who decided it and what happened.
Code. Sage reads Jason's ruling as covering those T3 Claude sessions.
The first launch under it is a Researcher thread on Sonnet 5.5
(9bac0794), researching Vikunja and Pocket ID.
46. **Sage's rulings on the slice 1 data model's open questions
(2026-10-04).** Source: Darkwing's note
`agents/darkwing/work/slice1-data-model-2026-10-04.md` (sha256
948b94ce…), section 6.
- 6.1, business files: accepted. A business file at
`~/.config/mosaic-dev/businesses/<id>.json` is a separate layer, not
a second system config. The user writes it, the stack never writes
it, and a missing or invalid file fails closed. `config.json`
doesn't change. Invariant 2 holds as written.
- 6.2, claims and events in `bus.sqlite`: accepted. They fall under
decision 44, item 7, with the same append-only rule.
- 6.3, human resolution: accepted. Stack-launched agents reach the bus
only through a broker that stamps role and run from the launch
record. A human resolution comes only from a `mosaic` CLI session
started outside any agent run. The brief will say plainly that
today's T3 seats, running as the same user, are advisory, not a
boundary.
- 6.4, tamper evidence: accepted. Triggers plus a schema check at
open. Hash chaining waits until a measure needs it.
- 6.5, coder vs. the worker invariant: accepted. Role agents are a new
kind of seat, launched with a role binding. Workers keep "no git, no
credentials".
- 6.6, the PM launching sessions: goes to Jason in PRDY round 3. It
changes who starts work (relaunch constraint 4). Decisions 42 and 45
cover Sage launching T3 roster seats, not a product role.
- 6.7, revoking a role: accepted. A revoke is gated in slice 1 and
cites a resolved decision.
- 6.8, Vikunja concurrency: accepted, provisionally. Compare, then
write. Only the assigned role writes a task's mutable fields, and
people's edits arrive as `task.changed.external` events. Revisit if
Researcher finds Vikunja supports conditional updates.
Also adopted from the note: credential scope is the hard boundary for
gated actions, and harness hooks are logging plus early stops. The
brief won't call hooks enforcement. A plain append-only rule isn't
enough: `INSERT OR REPLACE` overwrote a row in the prototype despite
UPDATE and DELETE triggers. Each table also needs a BEFORE INSERT
guard, which the prototype verified.