docs(slice1): schema v3a, task event rules (lead decision 56, Q3)
Two triggers: every task.* event names its task in subject, and task.created cites a human.input event and a requirement id. The task ref pattern is tightened on snapshots, decisions and subjects. Runs on Node 24.21.0 and 26.8.1 match apart from the version line. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
-- open-time schema check
|
||||||
|
check after create -> match
|
||||||
|
-- decisions.blocking
|
||||||
|
refuse raise without blocking -> NOT NULL constraint failed: decisions.blocking
|
||||||
|
refuse raise with blocking 2 -> CHECK constraint failed: blocking IN (0,1)
|
||||||
|
refuse raise blocking without task_ref -> a blocking decision cites the task it blocks
|
||||||
|
ok raise blocking gated with task_ref
|
||||||
|
ok raise non-blocking gated
|
||||||
|
ok raise blocking cross-role
|
||||||
|
view urgent_inbox -> [{"id":"d-3","task_ref":"vikunja:3/41"}]
|
||||||
|
ok resolve d-3 with A
|
||||||
|
view urgent_inbox after resolve -> []
|
||||||
|
-- events: closed kinds and the new kinds
|
||||||
|
refuse unknown kind task.deleted -> a task event names its task in subject
|
||||||
|
refuse unknown kind task.deleted with a subject -> CHECK constraint failed: kind IN ( 'session.launched', 'session.ended', 'action.allowed',
|
||||||
|
ok credential.expiring vikunja coder
|
||||||
|
ok credential.expired vikunja coder
|
||||||
|
ok credential.changed gitea pm
|
||||||
|
refuse credential.changed without instance -> credential events name a service and a role instance
|
||||||
|
refuse credential.expiring service github -> credential events name a service and a role instance
|
||||||
|
refuse task.missing without reason -> task.missing carries a reason, and the new project when moved
|
||||||
|
refuse task.missing reason deleted -> task.missing carries a reason, and the new project when moved
|
||||||
|
refuse task.missing without subject -> a task event names its task in subject
|
||||||
|
refuse task.missing moved without project -> task.missing carries a reason, and the new project when moved
|
||||||
|
ok task.missing not-found
|
||||||
|
ok task.missing moved to project 9
|
||||||
|
-- task events name their task (lead decision 56, Q3)
|
||||||
|
refuse task.state without subject -> a task event names its task in subject
|
||||||
|
refuse task.state subject PROJ-41 -> a task event names its task in subject
|
||||||
|
refuse task.state subject vikunja:3/41x -> a task event names its task in subject
|
||||||
|
refuse task.state subject vikunja:03/41 -> a task event names its task in subject
|
||||||
|
refuse task.state subject vikunja:3/4/1 -> a task event names its task in subject
|
||||||
|
ok task.state subject vikunja:3/41
|
||||||
|
ok human.input from the cli
|
||||||
|
ok human.input in another business
|
||||||
|
refuse task.created without request -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created request names an unknown event -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created request names a credential event -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created request from another business -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created request as a number -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created without requirement -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created requirement REQ-task-1 -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created requirement REQ-TASK-0 -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created without subject -> a task event names its task in subject
|
||||||
|
ok task.created cites h-1 and REQ-TASK-1
|
||||||
|
refuse decision with task_ref vikunja:3/41x -> CHECK constraint failed: task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NO
|
||||||
|
view e-3 is -> [{"kind":"credential.expiring"}]
|
||||||
|
view trail from h-1 -> [{"kind":"task.created","subject":"vikunja:3/50"}]
|
||||||
|
ok digest.sent
|
||||||
|
refuse launch.revoked by pm run -> only the human revokes or restores launching
|
||||||
|
ok launch.revoked by human
|
||||||
|
view launch_state -> [{"business":"mosaic-stack","state":"revoked"}]
|
||||||
|
ok launch.restored by human
|
||||||
|
view launch_state -> [{"business":"mosaic-stack","state":"allowed"}]
|
||||||
|
-- task_snapshots
|
||||||
|
refuse self without role and run -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL)
|
||||||
|
refuse poll with a role -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL)
|
||||||
|
refuse poll without via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
|
||||||
|
refuse poll without read_at -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
|
||||||
|
refuse self with via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
|
||||||
|
refuse unknown via webhook -> CHECK constraint failed: via IN ('board','cursor','task','reconcile')
|
||||||
|
refuse fields without bucket -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
|
||||||
|
refuse bucket as text -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
|
||||||
|
refuse gone on a board read -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
|
||||||
|
refuse gone on self -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
|
||||||
|
refuse bad task_ref -> CHECK constraint failed: task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9)
|
||||||
|
refuse task_ref vikunja:3/41x -> CHECK constraint failed: task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9)
|
||||||
|
refuse bad digest -> CHECK constraint failed: length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'
|
||||||
|
ok self X by coder, response :02
|
||||||
|
ok cursor X sent :04 (unchanged)
|
||||||
|
view external after cursor X -> []
|
||||||
|
ok cursor Y sent :06, same second (person edit)
|
||||||
|
view external after cursor Y -> [{"task_ref":"vikunja:3/41","via":"cursor"}]
|
||||||
|
ok self Z by coder (move to in-review), response :10
|
||||||
|
ok stale board read sent :09 shows Y
|
||||||
|
view external after self Z, stale board read -> []
|
||||||
|
ok stale cursor read, updated 11:59:00
|
||||||
|
view external after stale cursor read -> []
|
||||||
|
ok board read sent :30 agrees with Z
|
||||||
|
view external after board agrees -> []
|
||||||
|
ok person moves to blocked, updated unchanged, board sent :40
|
||||||
|
view external after person's move -> [{"task_ref":"vikunja:3/41","via":"board"}]
|
||||||
|
ok board read on vikunja:3/42 with no self row
|
||||||
|
ok cursor read on vikunja:3/44, done
|
||||||
|
view tasks_open -> [{"task_ref":"vikunja:3/41","bucket":14},{"task_ref":"vikunja:3/42","bucket":11}]
|
||||||
|
ok tombstone for vikunja:3/42 (GET 404)
|
||||||
|
view tasks_open after tombstone -> [{"task_ref":"vikunja:3/41","bucket":14}]
|
||||||
|
view external, all -> [{"task_ref":"vikunja:3/41","via":"board"},{"task_ref":"vikunja:3/42","via":"task"},{"task_ref":"vikunja:3/44","via":"cursor"}]
|
||||||
|
-- append-only on every table
|
||||||
|
refuse meta UPDATE -> meta is append-only
|
||||||
|
refuse meta DELETE -> meta is append-only
|
||||||
|
refuse meta INSERT OR REPLACE -> meta is append-only
|
||||||
|
refuse events UPDATE -> events is append-only
|
||||||
|
refuse events DELETE -> events is append-only
|
||||||
|
refuse events INSERT OR REPLACE -> events is append-only
|
||||||
|
refuse role_claims UPDATE -> role_claims is append-only
|
||||||
|
refuse role_claims DELETE -> role_claims is append-only
|
||||||
|
refuse role_claims INSERT OR REPLACE -> role_claims is append-only
|
||||||
|
refuse decisions UPDATE -> decisions is append-only
|
||||||
|
refuse decisions DELETE -> decisions is append-only
|
||||||
|
refuse decisions INSERT OR REPLACE -> decisions is append-only
|
||||||
|
refuse decision_events UPDATE -> decision_events is append-only
|
||||||
|
refuse decision_events DELETE -> decision_events is append-only
|
||||||
|
refuse decision_events INSERT OR REPLACE -> decision_events is append-only
|
||||||
|
refuse messages UPDATE -> messages is append-only
|
||||||
|
refuse messages DELETE -> messages is append-only
|
||||||
|
refuse messages INSERT OR REPLACE -> messages is append-only
|
||||||
|
refuse deliveries UPDATE -> deliveries is append-only
|
||||||
|
refuse deliveries DELETE -> deliveries is append-only
|
||||||
|
refuse deliveries INSERT OR REPLACE -> deliveries is append-only
|
||||||
|
refuse task_snapshots UPDATE -> task_snapshots is append-only
|
||||||
|
refuse task_snapshots DELETE -> task_snapshots is append-only
|
||||||
|
refuse task_snapshots INSERT OR REPLACE -> task_snapshots is append-only
|
||||||
|
-- tamper: drop a guard, reopen
|
||||||
|
check on reopen -> match
|
||||||
|
check after DROP TRIGGER -> MISMATCH
|
||||||
|
node 24.21.0 | sqlite 3.53.4 | journal: wal | tables: 8 | triggers: 35 | views: 4
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
-- open-time schema check
|
||||||
|
check after create -> match
|
||||||
|
-- decisions.blocking
|
||||||
|
refuse raise without blocking -> NOT NULL constraint failed: decisions.blocking
|
||||||
|
refuse raise with blocking 2 -> CHECK constraint failed: blocking IN (0,1)
|
||||||
|
refuse raise blocking without task_ref -> a blocking decision cites the task it blocks
|
||||||
|
ok raise blocking gated with task_ref
|
||||||
|
ok raise non-blocking gated
|
||||||
|
ok raise blocking cross-role
|
||||||
|
view urgent_inbox -> [{"id":"d-3","task_ref":"vikunja:3/41"}]
|
||||||
|
ok resolve d-3 with A
|
||||||
|
view urgent_inbox after resolve -> []
|
||||||
|
-- events: closed kinds and the new kinds
|
||||||
|
refuse unknown kind task.deleted -> a task event names its task in subject
|
||||||
|
refuse unknown kind task.deleted with a subject -> CHECK constraint failed: kind IN ( 'session.launched', 'session.ended', 'action.allowed',
|
||||||
|
ok credential.expiring vikunja coder
|
||||||
|
ok credential.expired vikunja coder
|
||||||
|
ok credential.changed gitea pm
|
||||||
|
refuse credential.changed without instance -> credential events name a service and a role instance
|
||||||
|
refuse credential.expiring service github -> credential events name a service and a role instance
|
||||||
|
refuse task.missing without reason -> task.missing carries a reason, and the new project when moved
|
||||||
|
refuse task.missing reason deleted -> task.missing carries a reason, and the new project when moved
|
||||||
|
refuse task.missing without subject -> a task event names its task in subject
|
||||||
|
refuse task.missing moved without project -> task.missing carries a reason, and the new project when moved
|
||||||
|
ok task.missing not-found
|
||||||
|
ok task.missing moved to project 9
|
||||||
|
-- task events name their task (lead decision 56, Q3)
|
||||||
|
refuse task.state without subject -> a task event names its task in subject
|
||||||
|
refuse task.state subject PROJ-41 -> a task event names its task in subject
|
||||||
|
refuse task.state subject vikunja:3/41x -> a task event names its task in subject
|
||||||
|
refuse task.state subject vikunja:03/41 -> a task event names its task in subject
|
||||||
|
refuse task.state subject vikunja:3/4/1 -> a task event names its task in subject
|
||||||
|
ok task.state subject vikunja:3/41
|
||||||
|
ok human.input from the cli
|
||||||
|
ok human.input in another business
|
||||||
|
refuse task.created without request -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created request names an unknown event -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created request names a credential event -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created request from another business -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created request as a number -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created without requirement -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created requirement REQ-task-1 -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created requirement REQ-TASK-0 -> task.created cites the human.input that asked for it and a requirement id
|
||||||
|
refuse task.created without subject -> a task event names its task in subject
|
||||||
|
ok task.created cites h-1 and REQ-TASK-1
|
||||||
|
refuse decision with task_ref vikunja:3/41x -> CHECK constraint failed: task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NO
|
||||||
|
view e-3 is -> [{"kind":"credential.expiring"}]
|
||||||
|
view trail from h-1 -> [{"kind":"task.created","subject":"vikunja:3/50"}]
|
||||||
|
ok digest.sent
|
||||||
|
refuse launch.revoked by pm run -> only the human revokes or restores launching
|
||||||
|
ok launch.revoked by human
|
||||||
|
view launch_state -> [{"business":"mosaic-stack","state":"revoked"}]
|
||||||
|
ok launch.restored by human
|
||||||
|
view launch_state -> [{"business":"mosaic-stack","state":"allowed"}]
|
||||||
|
-- task_snapshots
|
||||||
|
refuse self without role and run -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL)
|
||||||
|
refuse poll with a role -> CHECK constraint failed: (source = 'self') = (role IS NOT NULL AND run IS NOT NULL)
|
||||||
|
refuse poll without via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
|
||||||
|
refuse poll without read_at -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
|
||||||
|
refuse self with via -> CHECK constraint failed: (source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)
|
||||||
|
refuse unknown via webhook -> CHECK constraint failed: via IN ('board','cursor','task','reconcile')
|
||||||
|
refuse fields without bucket -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
|
||||||
|
refuse bucket as text -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
|
||||||
|
refuse gone on a board read -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
|
||||||
|
refuse gone on self -> CHECK constraint failed: json_type(fields, '$.bucket') IS 'integer' OR (source IS 'poll' A
|
||||||
|
refuse bad task_ref -> CHECK constraint failed: task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9)
|
||||||
|
refuse task_ref vikunja:3/41x -> CHECK constraint failed: task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9)
|
||||||
|
refuse bad digest -> CHECK constraint failed: length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'
|
||||||
|
ok self X by coder, response :02
|
||||||
|
ok cursor X sent :04 (unchanged)
|
||||||
|
view external after cursor X -> []
|
||||||
|
ok cursor Y sent :06, same second (person edit)
|
||||||
|
view external after cursor Y -> [{"task_ref":"vikunja:3/41","via":"cursor"}]
|
||||||
|
ok self Z by coder (move to in-review), response :10
|
||||||
|
ok stale board read sent :09 shows Y
|
||||||
|
view external after self Z, stale board read -> []
|
||||||
|
ok stale cursor read, updated 11:59:00
|
||||||
|
view external after stale cursor read -> []
|
||||||
|
ok board read sent :30 agrees with Z
|
||||||
|
view external after board agrees -> []
|
||||||
|
ok person moves to blocked, updated unchanged, board sent :40
|
||||||
|
view external after person's move -> [{"task_ref":"vikunja:3/41","via":"board"}]
|
||||||
|
ok board read on vikunja:3/42 with no self row
|
||||||
|
ok cursor read on vikunja:3/44, done
|
||||||
|
view tasks_open -> [{"task_ref":"vikunja:3/41","bucket":14},{"task_ref":"vikunja:3/42","bucket":11}]
|
||||||
|
ok tombstone for vikunja:3/42 (GET 404)
|
||||||
|
view tasks_open after tombstone -> [{"task_ref":"vikunja:3/41","bucket":14}]
|
||||||
|
view external, all -> [{"task_ref":"vikunja:3/41","via":"board"},{"task_ref":"vikunja:3/42","via":"task"},{"task_ref":"vikunja:3/44","via":"cursor"}]
|
||||||
|
-- append-only on every table
|
||||||
|
refuse meta UPDATE -> meta is append-only
|
||||||
|
refuse meta DELETE -> meta is append-only
|
||||||
|
refuse meta INSERT OR REPLACE -> meta is append-only
|
||||||
|
refuse events UPDATE -> events is append-only
|
||||||
|
refuse events DELETE -> events is append-only
|
||||||
|
refuse events INSERT OR REPLACE -> events is append-only
|
||||||
|
refuse role_claims UPDATE -> role_claims is append-only
|
||||||
|
refuse role_claims DELETE -> role_claims is append-only
|
||||||
|
refuse role_claims INSERT OR REPLACE -> role_claims is append-only
|
||||||
|
refuse decisions UPDATE -> decisions is append-only
|
||||||
|
refuse decisions DELETE -> decisions is append-only
|
||||||
|
refuse decisions INSERT OR REPLACE -> decisions is append-only
|
||||||
|
refuse decision_events UPDATE -> decision_events is append-only
|
||||||
|
refuse decision_events DELETE -> decision_events is append-only
|
||||||
|
refuse decision_events INSERT OR REPLACE -> decision_events is append-only
|
||||||
|
refuse messages UPDATE -> messages is append-only
|
||||||
|
refuse messages DELETE -> messages is append-only
|
||||||
|
refuse messages INSERT OR REPLACE -> messages is append-only
|
||||||
|
refuse deliveries UPDATE -> deliveries is append-only
|
||||||
|
refuse deliveries DELETE -> deliveries is append-only
|
||||||
|
refuse deliveries INSERT OR REPLACE -> deliveries is append-only
|
||||||
|
refuse task_snapshots UPDATE -> task_snapshots is append-only
|
||||||
|
refuse task_snapshots DELETE -> task_snapshots is append-only
|
||||||
|
refuse task_snapshots INSERT OR REPLACE -> task_snapshots is append-only
|
||||||
|
-- tamper: drop a guard, reopen
|
||||||
|
check on reopen -> match
|
||||||
|
check after DROP TRIGGER -> MISMATCH
|
||||||
|
node 26.8.1 | sqlite 3.53.4 | journal: wal | tables: 8 | triggers: 35 | views: 4
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Slice 1 prototype, v3a (lead decision 56, Q3)
|
||||||
|
|
||||||
|
Darkwing, 2026-10-04. Q3 asked whether a trigger or the broker enforces
|
||||||
|
two rules from Dewey's S5 questions. My answer is a trigger for both,
|
||||||
|
plus the broker setting the fields correctly in the first place. That's
|
||||||
|
how v2 and v3 already treat the `credential.*` and `task.missing`
|
||||||
|
bodies. Both rules depend only on the row being inserted and on rows
|
||||||
|
already in `events`, so SQL can check them. A broker bug that drops a
|
||||||
|
subject or the request id then fails at the insert. Without the trigger,
|
||||||
|
the bad row would only show up later as a gap in a trail. The limit is
|
||||||
|
the same as before: a process running as the same user can drop a
|
||||||
|
trigger, and the open-time digest only notices that afterwards.
|
||||||
|
|
||||||
|
`schema-v3a.sql` is a full schema that replaces v3. It isn't a
|
||||||
|
migration. The v1, v2 and v3 files are unchanged. `diff schema-v3.sql
|
||||||
|
schema-v3a.sql` shows every change:
|
||||||
|
- New trigger `events_task_subject`. Every `task.*` event must carry a
|
||||||
|
task ref in `subject`. That covers `task.created`, `task.assigned`,
|
||||||
|
`task.state`, `task.closed`, `task.changed.external`, `task.conflict`
|
||||||
|
and `task.missing`.
|
||||||
|
- New trigger `events_task_created_body`. A `task.created` body carries
|
||||||
|
`request`, the id of a `human.input` event in the same business that
|
||||||
|
is already in `events`. It also carries `requirement`, a PRD id shaped
|
||||||
|
like `REQ-TASK-1`. Addendum A section 3 made the `task.created` event
|
||||||
|
the authoritative holder of the requirement id, so I check it in the
|
||||||
|
same trigger. Checking that the id exists in the PRD version the
|
||||||
|
business file names stays with the broker, because the schema can't
|
||||||
|
read the PRD. Sage can drop the requirement half if it's unwanted.
|
||||||
|
- The task ref pattern is now `vikunja:<project>/<task>`, with both ids
|
||||||
|
positive integers, no leading zero and exactly one slash. v3's
|
||||||
|
`GLOB 'vikunja:[0-9]*/[0-9]*'` let `vikunja:3/41x` through. The
|
||||||
|
pattern applies to `task_snapshots.task_ref`, to `decisions.task_ref`
|
||||||
|
when it isn't null (v3 didn't check that column at all), and to task
|
||||||
|
event subjects.
|
||||||
|
- `events_task_missing_body` no longer checks the subject, because
|
||||||
|
`events_task_subject` does. Its message changed to match.
|
||||||
|
|
||||||
|
The broker enforces the rest of Q3's first rule. `action.allowed`,
|
||||||
|
`action.refused` and `review.*` events are sometimes about one task, but
|
||||||
|
the kind doesn't say when. A refused `task.create`, for example, has no
|
||||||
|
task yet. The broker sets `subject` on those whenever the action names a
|
||||||
|
task.
|
||||||
|
|
||||||
|
`proto-v3a.mjs` is `proto-v3.mjs` with these changes: the header, the
|
||||||
|
temp directory prefix, the schema file name, a new "task events name
|
||||||
|
their task" section, a `task_ref vikunja:3/41x` snapshot case, a second
|
||||||
|
unknown-kind case with a subject, and the append-only check's event row
|
||||||
|
(`h-1` instead of `e-2`, since `e-2` is now a refused insert).
|
||||||
|
|
||||||
|
Results: `proto-v3a-node24.txt` (Node 24.21.0 in the `node:24` image,
|
||||||
|
no network, the directory mounted read-only) and `proto-v3a-node26.txt`
|
||||||
|
(Node 26.8.1 on the host). Both use SQLite 3.53.4, and the outputs
|
||||||
|
differ only in the version line. Tables 8, triggers 35, views 4. The new
|
||||||
|
refusals:
|
||||||
|
- `task.state` with no subject, `PROJ-41`, `vikunja:3/41x`,
|
||||||
|
`vikunja:03/41` or `vikunja:3/4/1`;
|
||||||
|
- `task.created` with no request, a request naming an unknown event, a
|
||||||
|
credential event, a `human.input` from another business or a number;
|
||||||
|
with no requirement, `REQ-task-1` or `REQ-TASK-0`; or with no subject;
|
||||||
|
- a decision with `task_ref` `vikunja:3/41x`, and a snapshot with the
|
||||||
|
same ref.
|
||||||
|
|
||||||
|
`task.created` citing `h-1` and `REQ-TASK-1` is accepted, and the trail
|
||||||
|
view from `h-1` finds it. The v3 cases give the same results as before,
|
||||||
|
with one exception. "Unknown kind task.deleted" is now refused by the
|
||||||
|
subject trigger, because BEFORE INSERT triggers run before the table's
|
||||||
|
CHECK. The new case "with a subject" shows the kind CHECK still refuses
|
||||||
|
it.
|
||||||
|
|
||||||
|
Mutant: the same script against `schema-v3a.sql` without the `EXISTS`
|
||||||
|
clause accepts the unknown-event, credential-event and other-business
|
||||||
|
cases, and changes nothing else. It ran in `~/darkwing-scratch/v3a`,
|
||||||
|
with output in `mutant.txt` there.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Slice 1 prototype, v3a schema (v3 plus the task event rules of lead decision 56, Q3). Same pattern as proto-v3.mjs.
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { readFileSync, mkdtempSync } from "node:fs";
|
||||||
|
import { join } from "node:path"; import { tmpdir } from "node:os";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
const f = join(mkdtempSync(join(tmpdir(), "s1v3a-")), "bus.sqlite");
|
||||||
|
let db = new DatabaseSync(f, { timeout: 5000 });
|
||||||
|
db.exec(readFileSync(new URL("./schema-v3a.sql", import.meta.url), "utf8"));
|
||||||
|
let t = 0; const now = () => new Date(Date.UTC(2026, 9, 4, 12, 0, t++)).toISOString();
|
||||||
|
const tryit = (label, fn) => { try { fn(); console.log("ok ", label); } catch (e) { console.log("refuse", label, "->", e.message.replace(/\s+/g, " ").slice(0, 90)); } };
|
||||||
|
const show = (label, sql) => console.log("view ", label, "->", JSON.stringify(db.prepare(sql).all()));
|
||||||
|
const hex = (s) => createHash("sha256").update(s).digest("hex");
|
||||||
|
const schemaDigest = (d) => hex(d.prepare("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name").all().map((r) => `${r.type}|${r.name}|${r.sql}`).join("\n"));
|
||||||
|
|
||||||
|
console.log("-- open-time schema check");
|
||||||
|
db.prepare("INSERT INTO meta (key, value) VALUES ('schema_digest', ?)").run(schemaDigest(db));
|
||||||
|
const check = () => db.prepare("SELECT value FROM meta WHERE key = 'schema_digest'").get().value === schemaDigest(db) ? "match" : "MISMATCH";
|
||||||
|
console.log("check ", "after create ->", check());
|
||||||
|
|
||||||
|
console.log("-- decisions.blocking");
|
||||||
|
const dec = db.prepare("INSERT INTO decisions (id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,blocking) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
|
const opts = JSON.stringify([{ key: "A", text: "rotate" }, { key: "B", text: "wait" }]);
|
||||||
|
tryit("raise without blocking", () => db.exec(`INSERT INTO decisions (id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation) VALUES ('d-0','${now()}','mosaic-stack','coder','run-C','gated','credential.mint','human','?','${opts}','A')`));
|
||||||
|
tryit("raise with blocking 2", () => dec.run("d-1", now(), "mosaic-stack", "coder", "run-C", "gated", "credential.mint", "human", "Rotate?", opts, "A", "vikunja:3/41", 2));
|
||||||
|
tryit("raise blocking without task_ref", () => dec.run("d-2", now(), "mosaic-stack", "coder", "run-C", "gated", "credential.mint", "human", "Rotate?", opts, "A", null, 1));
|
||||||
|
tryit("raise blocking gated with task_ref", () => dec.run("d-3", now(), "mosaic-stack", "coder", "run-C", "gated", "credential.mint", "human", "Rotate coder vikunja token?", opts, "A", "vikunja:3/41", 1));
|
||||||
|
tryit("raise non-blocking gated", () => dec.run("d-4", now(), "mosaic-stack", "pm", "run-P", "gated", "deploy", "human", "Deploy?", opts, "B", null, 0));
|
||||||
|
tryit("raise blocking cross-role", () => dec.run("d-5", now(), "mosaic-stack", "coder", "run-C", "cross-role", "task.scope.change", "pm", "Widen scope?", opts, "B", "vikunja:3/41", 1));
|
||||||
|
show("urgent_inbox", "SELECT id, task_ref FROM urgent_inbox");
|
||||||
|
tryit("resolve d-3 with A", () => db.prepare("INSERT INTO decision_events (decision,at,op,by,choice,via) VALUES (?,?,?,?,?,?)").run("d-3", now(), "resolved", "jason", "A", "cli"));
|
||||||
|
show("urgent_inbox after resolve", "SELECT id FROM urgent_inbox");
|
||||||
|
|
||||||
|
console.log("-- events: closed kinds and the new kinds");
|
||||||
|
const ev = db.prepare("INSERT INTO events (id,at,business,kind,actor_role,actor_run,subject,body) VALUES (?,?,?,?,?,?,?,?)");
|
||||||
|
let n = 0; const e = (kind, role, run, body, subject = null) => ev.run(`e-${++n}`, now(), "mosaic-stack", kind, role, run, subject, JSON.stringify(body));
|
||||||
|
tryit("unknown kind task.deleted", () => e("task.deleted", "pm", "run-P", {}));
|
||||||
|
tryit("unknown kind task.deleted with a subject", () => e("task.deleted", "pm", "run-P", {}, "vikunja:3/41"));
|
||||||
|
tryit("credential.expiring vikunja coder", () => e("credential.expiring", null, null, { service: "vikunja", instance: "coder", expires: "2026-10-11" }));
|
||||||
|
tryit("credential.expired vikunja coder", () => e("credential.expired", null, null, { service: "vikunja", instance: "coder", decision: "d-3" }));
|
||||||
|
tryit("credential.changed gitea pm", () => e("credential.changed", null, null, { service: "gitea", instance: "pm", stat: { inode: 1, size: 41 } }));
|
||||||
|
tryit("credential.changed without instance", () => e("credential.changed", null, null, { service: "gitea" }));
|
||||||
|
tryit("credential.expiring service github", () => e("credential.expiring", null, null, { service: "github", instance: "pm" }));
|
||||||
|
tryit("task.missing without reason", () => e("task.missing", null, null, { reconcile: "r-1" }, "vikunja:3/40"));
|
||||||
|
tryit("task.missing reason deleted", () => e("task.missing", null, null, { reason: "deleted" }, "vikunja:3/40"));
|
||||||
|
tryit("task.missing without subject", () => e("task.missing", null, null, { reason: "not-found" }));
|
||||||
|
tryit("task.missing moved without project", () => e("task.missing", null, null, { reason: "moved" }, "vikunja:3/40"));
|
||||||
|
tryit("task.missing not-found", () => e("task.missing", null, null, { reason: "not-found" }, "vikunja:3/40"));
|
||||||
|
tryit("task.missing moved to project 9", () => e("task.missing", null, null, { reason: "moved", project: 9 }, "vikunja:3/43"));
|
||||||
|
console.log("-- task events name their task (lead decision 56, Q3)");
|
||||||
|
tryit("task.state without subject", () => e("task.state", "coder", "run-C", { bucket: 12 }));
|
||||||
|
tryit("task.state subject PROJ-41", () => e("task.state", "coder", "run-C", { bucket: 12 }, "PROJ-41"));
|
||||||
|
tryit("task.state subject vikunja:3/41x", () => e("task.state", "coder", "run-C", { bucket: 12 }, "vikunja:3/41x"));
|
||||||
|
tryit("task.state subject vikunja:03/41", () => e("task.state", "coder", "run-C", { bucket: 12 }, "vikunja:03/41"));
|
||||||
|
tryit("task.state subject vikunja:3/4/1", () => e("task.state", "coder", "run-C", { bucket: 12 }, "vikunja:3/4/1"));
|
||||||
|
tryit("task.state subject vikunja:3/41", () => e("task.state", "coder", "run-C", { bucket: 12 }, "vikunja:3/41"));
|
||||||
|
tryit("human.input from the cli", () => ev.run("h-1", now(), "mosaic-stack", "human.input", null, null, null, JSON.stringify({ via: "cli", text: "Add the broker push." })));
|
||||||
|
tryit("human.input in another business", () => ev.run("h-2", now(), "other", "human.input", null, null, null, JSON.stringify({ via: "cli", text: "x" })));
|
||||||
|
const tc = (body, subject = "vikunja:3/50") => e("task.created", "pm", "run-P", body, subject);
|
||||||
|
tryit("task.created without request", () => tc({ requirement: "REQ-TASK-1" }));
|
||||||
|
tryit("task.created request names an unknown event", () => tc({ request: "h-9", requirement: "REQ-TASK-1" }));
|
||||||
|
tryit("task.created request names a credential event", () => tc({ request: "e-3", requirement: "REQ-TASK-1" }));
|
||||||
|
tryit("task.created request from another business", () => tc({ request: "h-2", requirement: "REQ-TASK-1" }));
|
||||||
|
tryit("task.created request as a number", () => tc({ request: 1, requirement: "REQ-TASK-1" }));
|
||||||
|
tryit("task.created without requirement", () => tc({ request: "h-1" }));
|
||||||
|
tryit("task.created requirement REQ-task-1", () => tc({ request: "h-1", requirement: "REQ-task-1" }));
|
||||||
|
tryit("task.created requirement REQ-TASK-0", () => tc({ request: "h-1", requirement: "REQ-TASK-0" }));
|
||||||
|
tryit("task.created without subject", () => tc({ request: "h-1", requirement: "REQ-TASK-1" }, null));
|
||||||
|
tryit("task.created cites h-1 and REQ-TASK-1", () => tc({ request: "h-1", requirement: "REQ-TASK-1" }));
|
||||||
|
tryit("decision with task_ref vikunja:3/41x", () => dec.run("d-6", now(), "mosaic-stack", "coder", "run-C", "gated", "deploy", "human", "?", opts, "A", "vikunja:3/41x", 0));
|
||||||
|
show("e-3 is", "SELECT kind FROM events WHERE id = 'e-3'");
|
||||||
|
show("trail from h-1", "SELECT e.kind, e.subject FROM events e WHERE json_extract(e.body, '$.request') = 'h-1'");
|
||||||
|
tryit("digest.sent", () => e("digest.sent", null, null, { decisions: ["d-4"], transport: "discord-dm" }));
|
||||||
|
tryit("launch.revoked by pm run", () => e("launch.revoked", "pm", "run-P", {}));
|
||||||
|
tryit("launch.revoked by human", () => e("launch.revoked", null, null, { via: "cli" }));
|
||||||
|
show("launch_state", "SELECT business, state FROM launch_state");
|
||||||
|
tryit("launch.restored by human", () => e("launch.restored", null, null, { via: "cli" }));
|
||||||
|
show("launch_state", "SELECT business, state FROM launch_state");
|
||||||
|
|
||||||
|
console.log("-- task_snapshots");
|
||||||
|
const snap = db.prepare("INSERT INTO task_snapshots (at,business,task_ref,updated,etag,digest,fields,source,via,read_at,role,run) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
|
const at = (sec) => new Date(Date.UTC(2026, 9, 4, 13, 0, sec)).toISOString();
|
||||||
|
const self = (ref, updated, fields, sec, role, run) => snap.run(at(sec), "mosaic-stack", ref, updated, `"${hex(JSON.stringify(fields)).slice(0, 8)}"`, hex(JSON.stringify(fields)), JSON.stringify(fields), "self", null, null, role, run);
|
||||||
|
const poll = (ref, updated, fields, via, readSec) => snap.run(at(readSec + 1), "mosaic-stack", ref, updated, null, hex(JSON.stringify(fields)), JSON.stringify(fields), "poll", via, at(readSec), null, null);
|
||||||
|
const raw = (source, via, readAt, role, run, fields) => snap.run(at(59), "mosaic-stack", "vikunja:3/49", "2026-10-04T12:00:00Z", null, hex(JSON.stringify(fields)), JSON.stringify(fields), source, via, readAt, role, run);
|
||||||
|
// Buckets: 11 todo, 12 in-progress, 13 in-review, 14 blocked, 15 done.
|
||||||
|
const X = { title: "Add broker push", bucket: 12, done: 0 }, Y = { ...X, title: "Add broker push (Jason edit)" }, Z = { ...Y, bucket: 13 }, B = { ...Z, bucket: 14 }, W = { title: "Add broker push", bucket: 11, done: 0 };
|
||||||
|
const U = "2026-10-04T12:00:00Z";
|
||||||
|
tryit("self without role and run", () => raw("self", null, null, null, null, X));
|
||||||
|
tryit("poll with a role", () => raw("poll", "board", at(58), "pm", "run-P", X));
|
||||||
|
tryit("poll without via", () => raw("poll", null, at(58), null, null, X));
|
||||||
|
tryit("poll without read_at", () => raw("poll", "board", null, null, null, X));
|
||||||
|
tryit("self with via", () => raw("self", "board", at(58), "coder", "run-C", X));
|
||||||
|
tryit("unknown via webhook", () => raw("poll", "webhook", at(58), null, null, X));
|
||||||
|
tryit("fields without bucket", () => raw("poll", "cursor", at(58), null, null, { title: "x", done: 0 }));
|
||||||
|
tryit("bucket as text", () => raw("poll", "cursor", at(58), null, null, { title: "x", bucket: "in-progress", done: 0 }));
|
||||||
|
tryit("gone on a board read", () => raw("poll", "board", at(58), null, null, { gone: "not-found" }));
|
||||||
|
tryit("gone on self", () => raw("self", null, null, "pm", "run-P", { gone: "not-found" }));
|
||||||
|
tryit("bad task_ref", () => snap.run(at(59), "mosaic-stack", "PROJ-41", U, null, hex("x"), JSON.stringify(X), "poll", "board", at(58), null, null));
|
||||||
|
tryit("task_ref vikunja:3/41x", () => snap.run(at(59), "mosaic-stack", "vikunja:3/41x", U, null, hex("x"), JSON.stringify(X), "poll", "board", at(58), null, null));
|
||||||
|
tryit("bad digest", () => snap.run(at(59), "mosaic-stack", "vikunja:3/41", U, null, "abc", JSON.stringify(X), "poll", "board", at(58), null, null));
|
||||||
|
tryit("self X by coder, response :02", () => self("vikunja:3/41", U, X, 2, "coder", "run-C"));
|
||||||
|
tryit("cursor X sent :04 (unchanged)", () => poll("vikunja:3/41", U, X, "cursor", 4));
|
||||||
|
show("external after cursor X", "SELECT task_ref FROM task_external_changes");
|
||||||
|
tryit("cursor Y sent :06, same second (person edit)", () => poll("vikunja:3/41", U, Y, "cursor", 6));
|
||||||
|
show("external after cursor Y", "SELECT task_ref, via FROM task_external_changes");
|
||||||
|
tryit("self Z by coder (move to in-review), response :10", () => self("vikunja:3/41", U, Z, 10, "coder", "run-C"));
|
||||||
|
tryit("stale board read sent :09 shows Y", () => poll("vikunja:3/41", U, Y, "board", 9));
|
||||||
|
show("external after self Z, stale board read", "SELECT task_ref FROM task_external_changes");
|
||||||
|
tryit("stale cursor read, updated 11:59:00", () => poll("vikunja:3/41", "2026-10-04T11:59:00Z", W, "cursor", 20));
|
||||||
|
show("external after stale cursor read", "SELECT task_ref FROM task_external_changes");
|
||||||
|
tryit("board read sent :30 agrees with Z", () => poll("vikunja:3/41", U, Z, "board", 30));
|
||||||
|
show("external after board agrees", "SELECT task_ref FROM task_external_changes");
|
||||||
|
tryit("person moves to blocked, updated unchanged, board sent :40", () => poll("vikunja:3/41", U, B, "board", 40));
|
||||||
|
show("external after person's move", "SELECT task_ref, via FROM task_external_changes");
|
||||||
|
tryit("board read on vikunja:3/42 with no self row", () => poll("vikunja:3/42", "2026-10-04T12:01:00Z", W, "board", 41));
|
||||||
|
tryit("cursor read on vikunja:3/44, done", () => poll("vikunja:3/44", "2026-10-04T12:01:00Z", { ...W, bucket: 15, done: 1 }, "cursor", 41));
|
||||||
|
show("tasks_open", "SELECT task_ref, bucket FROM tasks_open ORDER BY task_ref");
|
||||||
|
tryit("tombstone for vikunja:3/42 (GET 404)", () => poll("vikunja:3/42", "2026-10-04T12:01:00Z", { gone: "not-found" }, "task", 45));
|
||||||
|
show("tasks_open after tombstone", "SELECT task_ref, bucket FROM tasks_open ORDER BY task_ref");
|
||||||
|
show("external, all", "SELECT task_ref, via FROM task_external_changes ORDER BY task_ref");
|
||||||
|
|
||||||
|
console.log("-- append-only on every table");
|
||||||
|
const keys = { meta: "key = 'schema_digest'", events: "id = 'h-1'", role_claims: "1", decisions: "id = 'd-3'", decision_events: "1", messages: "1", deliveries: "1", task_snapshots: "seq = 1" };
|
||||||
|
db.exec("INSERT INTO role_claims (at,business,role,op,holder_run,harness,by) VALUES ('x','mosaic-stack','pm','claim','run-P','pi','run-P')");
|
||||||
|
db.exec("INSERT INTO messages (id,at,business,from_role,from_run,to_role,class,decision,body) VALUES ('m-1','x','mosaic-stack','pm','run-P','human','RESULT','d-3','rotate')");
|
||||||
|
db.exec("INSERT INTO deliveries (message,at,op,transport) VALUES ('m-1','x','delivered','discord-dm')");
|
||||||
|
for (const [tbl, where] of Object.entries(keys)) {
|
||||||
|
const row = db.prepare(`SELECT * FROM ${tbl} WHERE ${where} LIMIT 1`).get();
|
||||||
|
const cols = Object.keys(row);
|
||||||
|
const col = cols.find((c) => !["seq", "id", "key"].includes(c));
|
||||||
|
tryit(`${tbl} UPDATE`, () => db.exec(`UPDATE ${tbl} SET ${col} = ${col} WHERE ${where}`));
|
||||||
|
tryit(`${tbl} DELETE`, () => db.exec(`DELETE FROM ${tbl} WHERE ${where}`));
|
||||||
|
tryit(`${tbl} INSERT OR REPLACE`, () => db.prepare(`INSERT OR REPLACE INTO ${tbl} (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`).run(...cols.map((c) => row[c])));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("-- tamper: drop a guard, reopen");
|
||||||
|
db.close(); db = new DatabaseSync(f, { timeout: 5000 });
|
||||||
|
console.log("check ", "on reopen ->", check());
|
||||||
|
db.exec("DROP TRIGGER task_snapshots_no_update");
|
||||||
|
db.close(); db = new DatabaseSync(f, { timeout: 5000 });
|
||||||
|
console.log("check ", "after DROP TRIGGER ->", check());
|
||||||
|
|
||||||
|
const count = (type) => db.prepare("SELECT count(*) n FROM sqlite_master WHERE type = ?").get(type).n;
|
||||||
|
console.log("node", process.versions.node, "| sqlite", db.prepare("SELECT sqlite_version() v").get().v, "| journal:", db.prepare("PRAGMA journal_mode").get().journal_mode, "| tables:", count("table") - 1, "| triggers:", count("trigger"), "| views:", count("view"));
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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 CHECK (kind IN (
|
||||||
|
'session.launched',
|
||||||
|
'session.ended',
|
||||||
|
'action.allowed',
|
||||||
|
'action.refused',
|
||||||
|
'task.created',
|
||||||
|
'task.assigned',
|
||||||
|
'task.state',
|
||||||
|
'task.closed',
|
||||||
|
'task.changed.external',
|
||||||
|
'task.conflict',
|
||||||
|
'task.missing',
|
||||||
|
'review.requested',
|
||||||
|
'review.verdict',
|
||||||
|
'human.input',
|
||||||
|
'config.refused',
|
||||||
|
'credential.expiring',
|
||||||
|
'credential.expired',
|
||||||
|
'credential.changed',
|
||||||
|
'launch.revoked',
|
||||||
|
'launch.restored',
|
||||||
|
'digest.sent')),
|
||||||
|
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 CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
|
||||||
|
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
|
||||||
|
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 TABLE task_snapshots (
|
||||||
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
at TEXT NOT NULL,
|
||||||
|
business TEXT NOT NULL,
|
||||||
|
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
|
||||||
|
updated TEXT NOT NULL,
|
||||||
|
etag TEXT,
|
||||||
|
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
|
||||||
|
fields TEXT NOT NULL CHECK (json_valid(fields)),
|
||||||
|
source TEXT NOT NULL CHECK (source IN ('self','poll')),
|
||||||
|
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
|
||||||
|
read_at TEXT,
|
||||||
|
role TEXT, run TEXT,
|
||||||
|
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
|
||||||
|
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
|
||||||
|
CHECK (json_type(fields, '$.bucket') IS 'integer'
|
||||||
|
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
|
||||||
|
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;
|
||||||
|
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||||
|
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||||
|
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||||
|
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
|
||||||
|
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
|
||||||
|
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
|
||||||
|
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
|
||||||
|
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
|
||||||
|
WHEN NEW.kind GLOB 'credential.*' AND (
|
||||||
|
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
|
||||||
|
OR json_extract(NEW.body, '$.instance') IS NULL)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
|
||||||
|
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
|
||||||
|
WHEN NEW.kind = 'task.missing' AND (
|
||||||
|
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
|
||||||
|
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
|
||||||
|
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
|
||||||
|
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
|
||||||
|
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
|
||||||
|
WHEN NEW.kind = 'task.created' AND (
|
||||||
|
json_type(NEW.body, '$.request') IS NOT 'text'
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
|
||||||
|
AND kind = 'human.input' AND business = NEW.business)
|
||||||
|
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
|
||||||
|
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
|
||||||
|
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
|
||||||
|
CREATE VIEW launch_state AS
|
||||||
|
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
|
||||||
|
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
|
||||||
|
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
|
||||||
|
CREATE VIEW task_external_changes AS
|
||||||
|
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
|
||||||
|
FROM task_snapshots p
|
||||||
|
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
|
||||||
|
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
|
||||||
|
WHERE p.source = 'poll'
|
||||||
|
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
|
||||||
|
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
|
||||||
|
CREATE VIEW tasks_open AS
|
||||||
|
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
|
||||||
|
FROM task_snapshots s
|
||||||
|
WHERE s.seq = (SELECT max(seq) FROM task_snapshots WHERE business = s.business AND task_ref = s.task_ref)
|
||||||
|
AND json_type(s.fields, '$.gone') IS NULL
|
||||||
|
AND json_extract(s.fields, '$.done') = 0;
|
||||||
|
CREATE VIEW urgent_inbox AS
|
||||||
|
SELECT d.id, d.business, d.task_ref, d.question, d.at
|
||||||
|
FROM decisions d
|
||||||
|
WHERE d.class = 'gated' AND d.blocking = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
|
||||||
Reference in New Issue
Block a user