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:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user