From bb7e37dda2f276b4ca4348a02c2ca9905d97d474 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 4 Oct 2026 14:19:14 -0500 Subject: [PATCH] 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 --- .../work/slice1-data-model-2026-10-04.md | 499 ++++++++++++++++++ .../work/slice1-proto/proto-node24.txt | 18 + .../work/slice1-proto/proto-node26.txt | 18 + agents/darkwing/work/slice1-proto/proto.mjs | 30 ++ agents/darkwing/work/slice1-proto/replace.mjs | 16 + agents/darkwing/work/slice1-proto/schema.sql | 108 ++++ docs/SESSIONS.md | 1 + docs/plans/2026-09-26_lead-decisions.md | 37 ++ 8 files changed, 727 insertions(+) create mode 100644 agents/darkwing/work/slice1-data-model-2026-10-04.md create mode 100644 agents/darkwing/work/slice1-proto/proto-node24.txt create mode 100644 agents/darkwing/work/slice1-proto/proto-node26.txt create mode 100644 agents/darkwing/work/slice1-proto/proto.mjs create mode 100644 agents/darkwing/work/slice1-proto/replace.mjs create mode 100644 agents/darkwing/work/slice1-proto/schema.sql diff --git a/agents/darkwing/work/slice1-data-model-2026-10-04.md b/agents/darkwing/work/slice1-data-model-2026-10-04.md new file mode 100644 index 00000000..77f49436 --- /dev/null +++ b/agents/darkwing/work/slice1-data-model-2026-10-04.md @@ -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, `/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//agent.json`, runs `mosaic-task.mjs resolve-role + roles/.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/.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/.json` | the user | role instances, arbiters, credential references, business variables | +| Project | `/.mosaic/project.json` | reviewed commits in that project | working branch, protected branches, suites, tracker project id, issue repo | +| Agent | `roles..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: }` or + `{env: }`. 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: `/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 +` 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:/`) 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/.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. diff --git a/agents/darkwing/work/slice1-proto/proto-node24.txt b/agents/darkwing/work/slice1-proto/proto-node24.txt new file mode 100644 index 00000000..945c2dbf --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-node24.txt @@ -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 diff --git a/agents/darkwing/work/slice1-proto/proto-node26.txt b/agents/darkwing/work/slice1-proto/proto-node26.txt new file mode 100644 index 00000000..945c2dbf --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto-node26.txt @@ -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 diff --git a/agents/darkwing/work/slice1-proto/proto.mjs b/agents/darkwing/work/slice1-proto/proto.mjs new file mode 100644 index 00000000..73962ba6 --- /dev/null +++ b/agents/darkwing/work/slice1-proto/proto.mjs @@ -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); diff --git a/agents/darkwing/work/slice1-proto/replace.mjs b/agents/darkwing/work/slice1-proto/replace.mjs new file mode 100644 index 00000000..3d2868fe --- /dev/null +++ b/agents/darkwing/work/slice1-proto/replace.mjs @@ -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); } + } +} diff --git a/agents/darkwing/work/slice1-proto/schema.sql b/agents/darkwing/work/slice1-proto/schema.sql new file mode 100644 index 00000000..e0253ac8 --- /dev/null +++ b/agents/darkwing/work/slice1-proto/schema.sql @@ -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; diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index a5c2139d..c4452568 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -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 diff --git a/docs/plans/2026-09-26_lead-decisions.md b/docs/plans/2026-09-26_lead-decisions.md index 8d4d4548..7cc8d672 100644 --- a/docs/plans/2026-09-26_lead-decisions.md +++ b/docs/plans/2026-09-26_lead-decisions.md @@ -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/.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.