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]>
500 lines
25 KiB
Markdown
500 lines
25 KiB
Markdown
# 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.
|