Compare commits

..
33 changed files with 1542 additions and 1283 deletions

No files matched your search

-132
View File
@@ -1,132 +0,0 @@
# Row 206 build notes (Launchpad): 193b2, thread shares (#1704)
Brief: docs/plans/2026-10-20_human-users-build.md, "Brief: 193b2, thread shares (schema 3l)"
(blob de3a6310b). Design: agents/morgana/work/queue-193/DESIGN.md at e2d161e2e (sha256 dd7fcb41…),
sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789.
## Base and chain
- Started on aca16404c (sage-2789) on branch launchpad/row206.
- 206 chains on 275 (193b1b, viewer verbs and board refusals), which Morgana is building. I rebase
onto Morgana's 275 sha when it comes, and onto 253's landing under a new branch name.
- At this base a user binds only with a collaborator grant (`#userGrants`, USER_LEVEL). Viewer
sessions arrive with 275, so the viewer column of design 4.3 is not testable here (sage-2818
Q4: the viewer column waits on the 275 rebase; 275 moves once more for Duckworth's B1,
duckworth-0166). On the 275
rebase, USER_VERBS and the viewer gate merge with the three share verbs, and the share verbs
must refuse a viewer (design 4.3: share, unshare and shares are "refused" for viewers).
- The live `--migrate` (3l→3m on the running bus) is not part of this build. It is Mos's go under
Q173, with a backup first and a STATE report after (mos-1310 item 4).
## What changed
- Schema 3l→3m. `thread_shares` as design 5.2, its index and three append-only triggers, in
schema.sql and in the new step `src/migrations/3l-3m.sql` with the same SQL text. SCHEMA_SHA256
d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9, SCHEMA_VERSION 3m.
`tests/fixtures/schema-3l.sql` is the 3l schema.sql (sha256 cd68512212ce…).
- `thread_shares` joins `timedTables` (store.mjs), the broker's clock list, `COUNTED` in
scripts/stack-release.sh and `LISTED` in stack-release.test (T46). It has `at`, so it goes in
the counted list, not the untimed one.
- broker.mjs:
- `#readable(s, t)` holds rules 1-4 of design 5.4. `#shared` reads the latest row per thread and
target. `#covered` reads the access document once per call and covers nothing on a read
failure, as `#userGrants` does.
- `#threadRead` admits the owner (with `#grantedThread`) or `#readable`, and serves only
`thread.messages`. `#thread` keeps the owner check for every write verb.
- `thread.share`, `thread.unshare`, `thread.shares` (design 5.3). `thread.list {shared:true}`
adds `owner`; the plain list keeps its shape (5.5).
- `replies` leaves out user thread messages except those in threads `#readable` admits.
`trail` needed no change: it already filters through `#hiddenMessage`, which calls `#readable`.
- USER_VERBS gains the three share verbs. `thread.shares` joins `#processingGate`'s read list.
- packages/bus/README.md: the verb rows, a "Thread shares" section, and the schema line (v3m, the
new sha, 23 tables, 90 triggers). schema.test counts 89 triggers because it drops one first.
## Choices and departures (for Sage and Duckworth)
1. Label 3l→3m, not 3i-3j (design) or 3l (brief title). 264a took 3k-3l, and labels go by
landing order (sage-2789).
2. A share covers every session of the thread, and `session.list` stays owner-only (sage-2818
Q1, from Sage's Q7 ruling, agents/sage/work/queue-264/RULINGS.md:72-77). broker.mjs keeps its
"no shares (Q7)" refusal: a sharee reads any session it can name but can't list them. A test
holds the refusal (human `thread-not-owned`, reader `read-only`, user `access-refused`).
**Follow-up**, bound to whichever of row 193 or the share wiring lands second: wire the sharee's
`session.list`. The bar is T1 sessions line 7 and REQ-USER-4 (candidate PRD 1509c7cc5,
docs/prd/mosaic-stack.md:1027-1052): read-only, and it needs current grants, the thread
owner's included.
3. A reader session matches shares as the business human (sage-2818 Q2: accepted, for the human
principal only). Design 4.3 says reader sessions "stay owner-only", and 5.5 says "messages in
other users' unshared threads hidden" for human, reader and tester. The reader sees what the
owner sees, shares to the owner included. Q2 also found a gap in the first tip (16b9c6df6): an
agent session has no owner, but it matched a `*` share, so a shared user message showed in an
agent's trail. `#readable` now returns false unless the session is the business human, a
reader or a user. A test binds an assistant run to the shared thread and a chief run with no
thread, and checks that neither (nor the tester) sees the message after a `*` share and a share
to the owner.
4. Access is read per request (sage-2818 Q3). Design 5.6's "30 s timer" and `access.reload` have
no path at this base: runtime.mjs:263 passes `access: () => loadAccess(file, context)`, read on
every request. The tests cover the per-request read (removed, expired, out of scope and
unreadable), which is the stricter case.
5. Rule 3 asks the owner's grant for any level, like rule 2 (design: "still has a live grant
covering t"). A user owner lowered to viewer keeps the thread readable to its sharees.
6. An unshare checks no coverage, so an owner can end a share whose target lost its grant.
7. `thread.shares` for a user goes through `#grantedThread`, so it needs the collaborator grant
to still cover the thread, like share and unshare. Design 4.3 says "own threads" with no grant
note; at this base every user is a collaborator, so the two agree until 275.
## Tests
New: tests/thread-shares.test.mjs, 15 tests: the read flow and the write refusals, `*`, rule 2,
rule 3 (with trail for human and reader), latest-row state, share-not-covered, target checks,
lost owner coverage, tester and reader refusals (18 `action.refused` events, no rows), trail and
replies, sessions, the sharee's `session.list` refusal (Q1), agents and the tester matching no
share (Q2), the 3l→3m migration and the 3l engine's refusal of a 3m store.
Pins moved 3l→3m: account-, chief-of-staff, comment-, gitea-, grant-, name-, spec-, usage- and
thread-migration, sessions, schema (23 tables, 89 triggers) and user-principal (USER_VERBS).
The engine copies in account-, gitea-, grant- and comment-migration gain 3l-3m.sql;
comment-migration's future step becomes 3m→3n. `later3l` in account-, gitea- and grant-migration
also leaves out `thread_shares` objects, and the gitea and grant `rows()` lists leave out the
table, since each compares a store migrated through 3m against an older one.
## Mutants (design section 8 numbers)
Each runs on a copy of the candidate tree with one edit, under `timeout -k 10 300`. The named
test must fail on an assertion; a thrown error or a refusal before the assertion doesn't count.
- M2. In `#readable`, delete the rule 2 line (`if (s.user && !this.#covered(s.user, ...)) return
false;`). Killed by "mutant 2: a share never widens a grant" (bob reads after his grant moves to
p2) and by the `*` test (carol reads).
- M3. In `#readable`, delete the rule 3 line. Killed by "mutant 3" (the human reads A after
alice's grant is removed).
- M4. In `#shared`, replace the latest-row lookup with any share row:
`SELECT op FROM thread_shares WHERE thread=? AND target=? AND op='share' LIMIT 1`. Killed by
"mutant 4" (bob still reads after the unshare).
- M7. In `thread.send`, replace `this.#thread(s, a.thread)` with `this.#threadRead(s, a.thread)`.
Killed by the first test (bob's send to A must refuse `thread-not-owned`).
- M8. In `thread.share`, delete the `share-not-covered` line. Killed by "mutant 8" (the share
to user:carol must refuse).
- M9. In `thread.share`/`thread.unshare`, replace `this.#thread(s, a.thread)` with
`this.#threadRow(s, a.thread)`. Killed by the first test (bob's share of A to `*` must refuse).
- Q2 probe (not a design mutant). In `#readable`, delete the human-principal line
(`if (!s.human && !s.reader && !s.user) return false;`). Killed by "only a human principal
matches a share" (the agent's trail shows the message after the `*` share).
## Run limits
From 264's table (agents/gizmoduck/work/queue-264/BUILD.md, Run limits), each at least 3x the
longest recorded run. A timeout with no failed test goes to Sage first; a timeout in the shell
phase goes to Mos unplaced (mos-1275).
| Run | LIMIT |
|---|---|
| stack-release.test alone | 1800 s |
| each other touched file alone | 300 s |
| bus suite | 900 s |
| cli suite | 1800 s |
| each scripts/test-*.sh | 900 s |
| each mutant probe | 300 s |
## Runs
None yet. Node and https git wait for Sage's CLEAR (Gander holds the slot for row 253 run 5).
+135
View File
@@ -0,0 +1,135 @@
# Row 258, E01: mail PRD addendum draft and schemas
Ludwig, 2026-10-22. Issue #1763. Assigned by sage-2802 under Q220. Reviewer: Della.
Base: aca16404c. Branch: ludwig/row258.
Sources: brief `docs/plans/2026-10-21_post-v1-row-briefs.md` (blob d7e5eaeea), "Brief: E01" and
"Gate: email rows". Email plan row E01 at origin/della/email-plan 179391055 (approved ludwig-0037).
Q2/Q222 terms (sage-2802): `mailboxScope` defaults to `[]`, changes are human-only, and the default-none
and refusal tests apply.
## Deliverable
| Path | What |
| --- | --- |
| `docs/prd/mail-addendum-draft.md` | REQ-MAIL-1 to 16, a draft. Each requirement names its rows. Two questions for Mos. |
| `packages/mail/schemas/*.schema.json` | source, data, access, service, content-key |
| `packages/mail/src/validate.mjs` | Closed-subset validator, binding, location, file and key-lifecycle checks |
| `packages/mail/tests/schema.test.mjs` | T1-T14, with T10b |
| `packages/mail/{package.json,README.md}` | Package manifest and notes |
Not changed: `docs/prd/mosaic-stack.md`, its requirements JSON and tests, `packages/plugins/src/kinds.mjs`,
`packages/business/src/vocabulary.mjs`, roles, `scripts/stack-copy.sh` COPY_PATHS. No new dependency.
## Choices and tradeoffs
1. **Validator.** A copy of the `packages/project/src/validate.mjs` pattern: a closed keyword set
that throws on an unknown keyword. Added `integer`, `minimum`, `maximum`, `uniqueItems` and `default`.
No JSON Schema library, so there is no dependency review. Tradeoff: two small validators to keep apart.
A shared one is a later refactor, not this row.
2. **Defaults live in the schema** (`default` keyword), read by `defaults(kind)`. One definition per
default and range, as Q222 asks.
3. **Ranges (implementer choice, for the reviewer to challenge).** Defaults come from the plan.
| Setting | Default | Min | Max |
| --- | --- | --- | --- |
| pollSeconds | 60 | 15 | 3600 |
| sliceObservations | 50 | 1 | 500 |
| sliceBytes | 10 MiB | 64 KiB | 64 MiB |
| pendingDeliveries | 1,000 | 1 | 100,000 |
| retainedBytes | 100 MiB | 1 MiB | 10 GiB |
| parserInputBytes | 30 MiB | 1 MiB | 64 MiB |
| mimeDepth | 10 | 1 | 32 |
| mimeParts | 100 | 1 | 1,000 |
| normalizedTextBytes | 256 KiB | 4 KiB | 4 MiB |
| attachmentPartBytes | 10 MiB | 0 | 50 MiB |
| attachmentMessageBytes | 10 MiB | 0 | 100 MiB |
| staleScanIntervals | 3 | 2 | 20 |
| pageSize | 20 | 1 | 100 |
| bodyDraftDays | 30 | 1 | 3,650 |
| attachmentDays | 7 | 1 | 3,650 |
| metadataDays | 365 | 1 | 3,650 |
Cross-checks: part bytes ≤ message bytes; metadataDays ≥ both content durations (tombstones outlive
content, D4).
4. **Fixed false until later rows.** `persistContent` and `automaticPrune` are `const false`: the plan
says metadata only until retention authority exists (E13), and settings never grant `data.delete`.
`sourceMutations` has `maxItems: 0`: no mutation capability exists (Q3). E13 or a later reviewed row
widens these.
5. **Human-only.** Settings records require `changedBy.kind: "human"`. The schema can't tell a real human
from an agent proxy. The E02 writer proves that, as the plan's E02 row says.
6. **Incomplete scopes.** An enabled source needs an adapter. Unattended wake needs at least one exact
sender: wake on with an empty allowlist would be on in name only. Grants need at least one right and
one mailbox. Service scopes are `["mail.read"]` only.
7. **Secret values.** Unknown keys are refused. All string values are also checked against secret
shapes (private key header, JWT, common token prefixes, URL userinfo password, `password=` style).
Messages name the path, never the value. This is a backstop, not a scanner.
8. **Content-key record location.** The plan names the key path. The lifecycle record's own file is left
to E03. `checkKeyLocation` and `checkProtectedFile` apply to both.
9. **Key lifecycle.** active → retired → expired, or active → expired. `keyTransitionAllowed` is a pure
check on one state edge. It doesn't persist state, stop a restored backup rolling a record back, or
destroy key copies; E03 owns that proof (della-0023 N1). `keyUse` is none outside
[createdAt, expiresAt), whatever the state says.
10. **v1 compatibility.** T13 asserts that kinds.mjs names no mail, vocabulary.mjs has no `mail.access`,
and the approved PRD has no REQ-MAIL. E05 and the approved addendum update T13 when they land.
11. **Use helpers fail closed (della-0023 F1).** `effectiveMailboxes` and `keyUse` validate their
records first and read the clock only from a valid `Date`. An invalid record or clock, or a time
outside [issuedAt, expiresAt) or [createdAt, expiresAt), gives no read and no key use. Tradeoff: a
caller with a slightly malformed but otherwise usable grant gets nothing, not a partial read. E02
still owns principal, right, manual-mode and revoke enforcement.
12. **Placement compares resolved paths too (della-0023 F2).** `checkSlotLocation` and
`checkKeyLocation` check the paths as written first, so planned paths that don't exist yet still
work. Then they resolve the nearest existing ancestor of the secret path and of each forbidden root,
and refuse containment. That catches a configDir linked into dataRoot and a root named through an
alias. `checkProtectedFile` now needs the file lexically under configDir, refuses a symbolic link
on configDir itself or any directory below it, and takes the forbidden roots for the same resolved
comparison. Symbolic links above configDir (a linked `/home`, say) stay allowed: they don't move
the secret relative to the roots, and the resolved comparison still covers them. Tradeoff: one
`realpath` per root on each check.
13. **Draft wording (mos-1313).** W1: rulings are cited as "DECISIONS Q…", plan questions as "email
plan Q…", and "Q4" is replaced by what it stands for. W2: retention says expiry stops serving an
item and removal waits for `data.delete`. W3: an invalid mail setting is refused when written and
holds its source with an alert when found at load; no fallback to the default. W4: the questions
section is gone; Mos's two answers are recorded in the status lines.
## Test plan (not run: Gander holds the slot, sage-2802)
`cd packages/mail && node --test tests/schema.test.mjs`. It writes only under `os.tmpdir()`, which the gate
sets to a `mktemp -d /mnt/storage/scratch/tmp/gXXXXXX` directory. No network and no `~/.mosaic-dev`.
Static checks done: schemas written by an uncommitted scratch script (the JSON files are the source),
JSON parses, every keyword is in the validator's closed set, every required key has a property, T13/T14 assertions checked against
the repository text with python3 -B, secret and wildcard patterns checked against every fixture and
sentinel. Node was not run.
Existing suites to run whole, because they scan every package's `src` or copy tracked package files:
- `packages/bus/tests/credential-store.test.mjs` (credential use sites)
- `packages/bus/tests/host-run.test.mjs` (T24 kind lists and use sites)
- `packages/plugins/tests/harness.test.mjs` (harness names outside plugins)
- `packages/cli/tests/stack-copy.test.mjs`, `packages/cli/tests/stack-release.test.mjs` (fixture copies)
Mutants, named by Della (della-0023). Each must die on a semantic assertion in the whole
`schema.test.mjs`, run on a CLEAR slot. The wildcard tests stay.
| Mutant | Change | Must fail |
| --- | --- | --- |
| M1 | `mailboxScope` default nonempty | T1/T2 |
| M2 | allow an agent settings author | T3 |
| M3 | remove the source/business binding comparison | T8 |
| M4 | disable secret-shape rejection | T6 |
| M5 | omit the grant intersection | T2 |
| M6 | omit the forbidden-root exclusion | T10 (config under dataRoot) |
| M7 | allow mode 0640 | T11 |
| M8 | allow retired → active | T12 |
| M9 | bypass an enforced numeric lower or upper comparison in `visit` | T9 |
| M10 | omit the malformed-state/time guard | T2/T12 lifetime and malformed cases |
| M11 | omit the canonical or root-symlink guard | T10b/T11 |
## Round 2 (della-0023, mos-1313)
Round 1 was a8f0cf924, CHANGES (della-0023). Round 2 adds F1, F2 and N1 above, and Mos's W1-W4 on the
draft. New cases: T2 lifetime edges and malformed grant or clock, T12 lifetime edges and malformed key
or clock, T10b (configDir linked into dataRoot with a real 0600 key, a planned slot through the link,
a dataRoot alias), T11 (file outside configDir, unnormalized path, linked configDir, root alias).
Static checks only; Node not run (Gander holds the slot).
+164
View File
@@ -0,0 +1,164 @@
# PRD addendum draft: mail intake and access
- Status: draft, not approved. Ludwig drafted it for email plan row E01
(Vikunja 460, issue #1763). Its wording goes to Mos first, then Jason.
It does not change `docs/prd/mosaic-stack.md`. Once Jason approves it,
it lands there as a new PRD version, and only then does any requirement
here bind.
- Source: `docs/plans/2026-10-21_email-plan.md`, Della's revision
179391055, approved by Ludwig (ludwig-0037). DECISIONS Q217 settles
the mailbox (one agent mailbox on woltje.com at InMotion, which Jason
creates). DECISIONS Q222 settles personal mail (none for now) and the
settings rule: things are tunable, not hardcoded.
- Shape (mos-1313): once approved, this lands as one new PRD version with
a Mail section, in its own reviewed row. `mail.access` stays a hard
wait in that version.
- Scope: post-v1. None of it is needed for 2026-10-27.
- Q numbers: "DECISIONS Q…" is a ruling. "Email plan Q…" is a question
still open in the email plan.
## How to read this draft
Every limit, scope and time below is a setting with a default, unless
Jason fixed it himself. The default is the starting value, not a rule.
The schemas in `packages/mail/schemas/` hold each default and the range a
setting may take. Implementers and reviewers set that range. Widening it
needs a reviewed schema and test change. Only an authenticated human
changes a setting, through the audited CLI and later the Console. An
agent can't change one, not even to narrow it. For mail, an invalid
setting doesn't fall back to its default. A write with an invalid value
is refused. An invalid value found when the service loads holds that
source and raises an alert.
Each requirement lists the build rows that deliver it. The rows are in
the email plan's "Ordered build rows".
## Requirements
### Mail
- **REQ-MAIL-1.** The stack may run one mail service per instance. It is
off by default and opens no connection while off. A disabled source
makes no probe and no read. Each configured source has one leased
owner. Accounts stay separate by instance, business and source. One
service never means one shared credential or mailbox.
Rows: E01, E03, E14.
- **REQ-MAIL-2.** Each source has a mailbox scope, `mailboxScope`. It
defaults to none, which reads nothing, agent ingress included. A human
sets exact mailbox IDs. No wildcard is accepted. The scope is
rechecked before each scan and fetch. A mailbox removed from scope is
fenced before in-flight content returns. A read is the intersection of
the source scope, the provider's rights and the principal's current
grant. Widening a scope grants no agent anything. No personal mailbox
is enrolled until a later consent and grant (DECISIONS Q222).
Rows: E01, E02, E13.
- **REQ-MAIL-3.** A new plug-in kind, `mail-source`, carries providers
(REQ-PLUG-1). Its modules export `descriptor`, `scan`, `fetch` and
`health`. A module only reads: it never sends, deletes, labels, moves
or marks mail read. Microsoft 365 (Graph) and IMAP are the first
modules. POP3, Gmail API and JMAP wait for a named need. The kind
enters `packages/plugins/src/kinds.mjs` with its first production
module. That waits for the approved PRD version, and for the source,
grant and route approvals in REQ-MAIL-4, 7 and 8.
Rows: E05, E06.
- **REQ-MAIL-4.** A source connects through its own service principal,
`mail-service`. This is a new approved extension, not an exception to
REQ-CRED-1. Its record names the business, source, provider, mailbox,
scopes (read only), slot path, rotation date, approval and revision.
The slot sits under
`<configDir>/secrets/mosaic-stack/mail/<business>/<source>/`, never in
the repository or dataRoot. Agents never see the credential. A missing,
expired or revoked slot fails closed. Authentication may retry once,
then stops and alerts.
Rows: E01, E02.
- **REQ-MAIL-5.** Intake keeps a durable record of what it saw. The
provider cursor and the new observations commit together. A message's
identity is its provider ID within business, source and account, never
Message-ID alone. Delivery uses a stable key and a receipt at the
recipient, so a crash causes no second task. First activation starts
from now and acts on no old mail. Importing history is a separate
approved window. Per-source quotas default to 1,000 pending deliveries
and 100 MiB of content. A full quota, a full disk or missing audit
storage holds intake and raises an alert.
Rows: E03.
- **REQ-MAIL-6.** Parsing is bounded. Defaults: 30 MiB input, depth 10,
100 parts, 256 KiB of normalized text. Bad or truncated mail is
quarantined, never dropped. Active content never runs. Attachments
start as metadata. An authorized fetch quarantines the bytes, by
default up to 10 MiB per part and per message. Extracting attachment
content stays Jason's choice (email plan Q8). If he chooses it, extraction runs
isolated with no network and a scanner.
Rows: E04, E16.
- **REQ-MAIL-7.** A route table maps trusted delivery evidence to a Stack
user or role instance. It lives in the organization file and changes
only through the human-approved write path. A route grants no read
right. No agent reroutes mail or widens its own route. Bcc, ambiguous
alias or forwarded mail goes to restricted review. Unknown addresses
create no agent.
Rows: E07, E08.
- **REQ-MAIL-8.** Agent access to mail needs a `mail.access` grant. It is
separate from `credential.mint`. The chief raises it, and the human
approves the exact principal, source, mailboxes, rights and expiry.
Metadata, body, attachment and draft are separate rights. Each read
rechecks the current grant. A revoke fences new reads and in-flight
reads before content returns. Manual mode asks the human for each use.
`mail.access` is a new vocabulary action, so it is a hard wait
(REQ-WAIT-3) until a PRD version Jason approves says otherwise
(REQ-WAIT-2).
Rows: E02, E09.
- **REQ-MAIL-9.** Every harness gets the same mail tools through the
broker: list, get, thread, attachment, acknowledge, create a local
draft and health. A list page defaults to 20 items. Acknowledge
changes Stack state only. A draft is a local unsent record. There is
no send tool and no reroute tool.
Rows: E09.
- **REQ-MAIL-10.** Mail content is untrusted. Tool results carry
`untrusted: true`. A sender can't approve a decision, change policy,
mint a grant or pose as the human. Mail never wakes an agent by
default (`unattendedWake` is off, and its sender allowlist is empty).
A human may turn it on for a route. Then a wake also needs aligned
DMARC, a current grant and admission by the system manager
(DECISIONS Q198).
The woken work runs in a fresh restricted profile, and anything it
starts keeps `source: mail`.
Rows: E10.
- **REQ-MAIL-11.** A mail-triggered decision goes through the stack's one
decision module (DECISIONS Q207). Its input is metadata only, inside a closed
schema. The mail service never calls a model itself. Without a model,
deterministic intake keeps working and model work is held.
Rows: E11.
- **REQ-MAIL-12.** Mail health reports real progress: last scan, last
cursor commit, lag, backlog and credential state. A live process alone
is not healthy. Outages pause only the affected source. Recovery
follows the system manager's protected mode and its recovery latch
(DECISIONS Q198). A health check sends no mail and calls no model.
Rows: E12.
- **REQ-MAIL-13.** Mail data lives under `<dataRoot>/mail/<business>/`
and never enters git or an image. By default the stack keeps metadata
only. Content keys sit under
`<configDir>/secrets/mosaic-stack/mail-content/<business>/`, outside
dataRoot and its backups, mode 600, with no symbolic link. Retention
defaults: bodies and drafts 30 days, attachments 7 days, metadata 365
days. When an item's time runs out, the stack stops serving it. A
retention setting never authorizes deletion: removing the item waits
for `data.delete` or a retention authority Jason approves later. A
restored backup must not bring back expired content or redeliver old
work.
Rows: E01, E03, E13.
- **REQ-MAIL-14.** The mail service never sends. No SMTP, Graph send,
forward, auto-reply or emailed digest. The email specialist reads and
drafts, never sends (R21). A future sender needs its own plan and
`message.external` approval.
Rows: E05, E06, E09, E14.
- **REQ-MAIL-15.** Every mail limit, scope and retention time is a
setting. The schema holds its default and range. Only a human changes
it. A change is versioned and audited with who made it. An invalid
value is refused when written, and holds its source with an alert when
found at load. Settings never
override grants, hard waits or the system manager's contract.
Rows: E01, E04, E13.
- **REQ-MAIL-16.** A live pilot is separate. It needs Jason's mailbox,
the approved PRD version, the source, grant and route approvals in
REQ-MAIL-4, 7 and 8, and a reviewed re-pin. It sends no test mail, and it
is judged on evidence for each message, not counts.
Rows: E15.
+7 -33
View File
@@ -240,12 +240,10 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
| `thread.list` | `{project?,shared?}`; human sessions only. The caller's own threads, oldest first. With `shared: true` (row 206), the threads others own that the caller may read (Thread shares, below), each row adding `owner`. |
| `thread.list` | `{project?}`; human sessions only. The caller's own threads, oldest first. |
| `thread.open` | `{thread,session?}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
| `thread.send` | `{thread,session?,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). A thread shared with the caller reads too, any session of it (row 206). Writes nothing. |
| `thread.share`, `thread.unshare` | `{thread,to}`; the thread's owner only (row 206, #1704). `to` is `user:<id>`, the business human's name or `*`; yourself refuses `invalid-request`. A share refuses `share-exists` when one is in force, and `share-not-covered` when a `user:` target has no live grant covering the thread. An unshare refuses `share-not-found` when none is. Each writes one `thread_shares` row. Returns `{thread,to,op}`. |
| `thread.shares` | `{thread}`; the thread's owner only. The shares in force, `[{to,at,by}]`, oldest first. Writes nothing. |
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). Writes nothing. |
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default}]`. Writes nothing. |
| `session.create` | `{thread,name,op}`; human and tester sessions. A new session of the thread, metadata only. A retry with the same `op` and name returns the first row; another name under that `op` refuses `session-op-conflict`, and a taken name `session-exists`. Returns `{id,thread,name}`. |
@@ -285,31 +283,7 @@ any run but the thread's current holder.
A user reaches only their own threads (REQ-USER-2): `thread.open`, `thread.send` and
`thread.messages` refuse `thread-not-found` for an unknown id and `thread-not-owned` for
another user's thread, unless it is shared with them (Thread shares, below).
### Thread shares (row 206, #1704)
Schema `3m` adds `thread_shares`, append-only: one row per `share` or `unshare`, and the latest
row per thread and target is the share's state. A thread's owner shares it read-only with a
user (`user:<id>`), the business human or everyone (`*`). The 3l→3m step only adds the table,
so every existing thread stays private to its owner.
A session may read a thread it doesn't own only when all four hold (row 193 design 5.4):
1. a share is in force to the caller's owner string or to `*`;
2. a user caller has a live grant, of any level, covering the thread's project and instance;
3. a thread owned by a user is still covered by that user's grant;
4. neither the caller nor the owner is the tester.
Only a human principal matches a share: the business human, a reader session as that human, or
a user. An agent session matches none, `*` included, even when it is bound to the thread.
A reader session reads as the business human. A sharee can't call `session.list` on the
thread; it stays owner-only until row 193's share wiring. The check runs on every request against the
access document the broker reads then, so a removed, expired or unreadable grant or an
unshare ends reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
`thread.send` and the share verbs refuse it `thread-not-owned`. `trail` and `replies` show a
user's thread messages to the owner and readers while the check holds. The tester can't call
the share verbs (`tester-refused`). Tests: `tests/thread-shares.test.mjs`.
another user's thread.
`threadsState(business)` is a trusted launcher API, like `bindLaunch`, not a socket verb.
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
@@ -792,10 +766,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3m, SHA-256
`d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9`.
It has 23 tables, 90 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3m`, WAL,
`schema.sql` is v3l, SHA-256
`cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a`.
It has 22 tables, 87 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3l`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
-15
View File
@@ -478,18 +478,3 @@ CREATE TABLE gitea_grants (
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
-- Row 206 (193b2, #1704, row 193 design 5.2): a thread owner's read-only shares. The latest row per
-- (thread, target) is the share's state. Append-only.
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
+13 -113
View File
@@ -131,9 +131,7 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
// FED R3 (#414, federation design 3.5): the collaborator level is this exact list. Every other verb
// refuses a user access-refused before its own checks: owner verbs, board reads, message.*,
// decision.*, project files, spec.read, assistant.stop, launch.*, name.set and usage.*.
// Row 206 (193b2, row 193 design 4.3): and the share verbs on a user's own threads.
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
'thread.share', 'thread.unshare', 'thread.shares']);
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
// The level a user's grant must reach for USER_VERBS (R1's covers, morgana-0005).
const USER_LEVEL = 'collaborator';
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
@@ -468,7 +466,6 @@ export class Broker {
'session_defaults',
'thread_claims',
'thread_opens',
'thread_shares',
]) {
const at = store.get(`SELECT max(at) at FROM ${table}`).at;
if (at) {
@@ -514,7 +511,7 @@ export class Broker {
if (s.human || s.reader || s.tester) return;
if (!['inbox', 'agents', 'tasks', 'trail', 'decisions', 'replies', 'messages', 'state', 'usage',
'requirements', 'spec.read', 'project.list', 'project.files', 'project.file', 'library.file', 'library.status',
'thread.list', 'thread.messages', 'thread.shares', 'names', 'instances', 'routes', 'hosts.status', 'grants.status',
'thread.list', 'thread.messages', 'names', 'instances', 'routes', 'hosts.status', 'grants.status',
'processing.status', 'role.release', 'usage.end'].includes(verb)) this.#processingWork();
}
reconcileProcessing() { this.#processingMirror(this.#processingState()); }
@@ -756,22 +753,12 @@ export class Broker {
return thread;
}
#thread(s, id) {
const t = this.#threadRow(s, id);
const t = this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business);
if (!t) fail('thread-not-found');
// REQ-USER-2: a user reaches only their own threads.
if (t.owner !== this.#owner(s)) fail('thread-not-owned');
return t;
}
#threadRow(s, id) {
return this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business) ?? fail('thread-not-found');
}
// Row 206 (193b2, design 5.4): the thread a read verb names, the caller's own or one #readable
// admits. Only thread.messages takes it; every write verb keeps #thread's owner check.
#threadRead(s, id) {
const t = this.#threadRow(s, id);
if (t.owner === this.#owner(s)) return this.#grantedThread(s, t);
if (!this.#readable(s, t)) fail('thread-not-owned');
return t;
}
// Row 177 (#1675): who owns the threads a session makes. A tester's are `tester`'s, never the human's.
#owner(s) {
return s.tester ? 'tester' : s.user ? 'user:' + s.user : s.human;
@@ -815,62 +802,10 @@ export class Broker {
);
return Boolean(t) && t.owner.startsWith('user:') && t.owner !== this.#owner(s) && !this.#readable(s, t);
}
// Row 206 (193b2, design 5.4): may a session read thread `t` it doesn't own? Rule 4 first: a
// tester's thread is never shared, and a tester is never a share's target and holds no grant. A
// reader session is the owner's board view, so it reads what is shared with the owner. Only a
// human principal (the business human, a reader as that human, a user) matches a share: an agent
// session, even one bound to the thread, matches none, `*` included (sage-2818 Q2).
// Row 193 design 5.4. Rule 1 needs a share in force, and there is no thread_shares table before
// 193b2 (schema 3j), so nobody reads another's thread yet. 193b2 adds the share rule and rules 2-4.
#readable(s, t) {
if (s.tester || t.owner === 'tester') return false;
if (!s.human && !s.reader && !s.user) return false;
const me = s.reader ? this.#business(s.business).human : this.#owner(s);
// Rule 1: a share in force to the caller or to everyone.
if (!this.#shared(t.id, me) && !this.#shared(t.id, '*')) return false;
// Rule 2: a share never widens a grant. The owner needs none.
if (s.user && !this.#covered(s.user, s.business, t)) return false;
// Rule 3: a thread whose user owner has lost coverage is hidden from its sharees too.
if (t.owner.startsWith('user:') && !this.#covered(t.owner.slice(5), s.business, t)) return false;
return true;
}
// A share's state is its latest row for the thread and target (design 5.2, Duckworth N12).
#shared(thread, target) {
return this.#store.get('SELECT op FROM thread_shares WHERE thread=? AND target=? ORDER BY seq DESC LIMIT 1', thread, target)?.op === 'share';
}
// Does a live grant of any level cover thread `t` for `user`? One read of the access file, as
// #userGrants does; any failure to read covers nothing (design 5.6, H3).
#covered(user, business, t) {
let doc = null;
try {
doc = this.#access?.() ?? null;
} catch {
doc = null;
}
const now = new Date();
return (doc?.grants ?? []).some((g) => g.user === user &&
covers(g, { business, project: t.project, instance: t.instance }, now, { level: 'viewer' }));
}
// The threads another principal owns that the session may read, oldest first.
#readableThreads(s, project) {
const me = s.reader ? this.#business(s.business).human : this.#owner(s);
return this.#store.all(
"SELECT t.* FROM threads t WHERE t.business=? AND t.owner IS NOT ? AND (? IS NULL OR t.project=?) AND EXISTS(SELECT 1 FROM thread_shares x WHERE x.thread=t.id AND x.target IN (?,'*')) ORDER BY t.seq",
s.business,
this.#owner(s) || null,
project ?? null,
project ?? null,
me || null,
).filter((t) => this.#readable(s, t));
}
// Row 206 (design 5.3): a share's target, an owner string: `user:<id>`, the business human's
// name or `*`. A share with yourself is invalid.
#shareTarget(s, to) {
const b = this.#business(s.business);
if (typeof to !== 'string') fail('invalid-request');
const user = to.startsWith('user:') ? to.slice(5) : null;
if (!(to === '*' || to === b.human || (user !== null && ID_PATTERN.test(user) && !['human', 'tester', 'user', b.human].includes(user))))
fail('invalid-request');
if (to === this.#owner(s)) fail('invalid-request');
return to;
return false;
}
#agent(s) {
if (s.tester) fail('tester-refused');
@@ -5814,13 +5749,8 @@ export class Broker {
// and the tester's lists only its own (row 175 listed it the business human's).
case 'thread.list': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['project', 'shared']);
keys(a, ['project']);
if (a.project !== undefined) identifier(a.project);
if (a.shared !== undefined && a.shared !== true) fail('invalid-request');
// Row 206 (193b2, design 4.3): another's threads shared with the caller, each naming its
// owner. The plain list keeps its shape.
if (a.shared)
return this.#readableThreads(s, a.project).map((t) => ({ id: t.id, at: t.at, project: t.project, instance: t.instance, name: t.name, owner: t.owner }));
// FED R3 (design 3.5): a user's own threads that its grants still cover.
return this.#store.all(
'SELECT id,at,project,instance,name FROM threads WHERE business=? AND owner=?' +
@@ -5871,12 +5801,11 @@ export class Broker {
// A user's message, like a tester's, is never the owner's authority evidence (design 3.5).
return { id: mid, request: s.tester || s.user ? null : this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) };
}
// The thread's history, oldest first. Reading writes nothing. Row 206 (193b2): a thread shared
// with the caller reads too, every session of it, since a share is the whole thread's.
// The thread's history, oldest first. Reading writes nothing.
case 'thread.messages': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session', 'after', 'limit'], ['thread']);
const t = this.#threadRead(s, a.thread);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const session = this.#threadSession(t, a.session);
const limit = a.limit ?? THREAD_PAGE;
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
@@ -5895,34 +5824,6 @@ export class Broker {
after,
);
}
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
// of any level that covers it. Each writes one row; the latest row per target is the state.
case 'thread.share':
case 'thread.unshare': {
if (!s.user) this.#human(s);
keys(a, ['thread', 'to'], ['thread', 'to']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const to = this.#shareTarget(s, a.to);
const op = verb === 'thread.share' ? 'share' : 'unshare';
if (op === 'unshare' && !this.#shared(t.id, to)) fail('share-not-found');
if (op === 'share') {
if (to.startsWith('user:') && !this.#covered(to.slice(5), s.business, t)) fail('share-not-covered');
if (this.#shared(t.id, to)) fail('share-exists');
}
this.#store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', this.#now(), s.business, t.id, op, to, this.#owner(s));
return { thread: t.id, to, op };
}
// The shares in force on the caller's own thread, oldest first.
case 'thread.shares': {
if (!s.user) this.#human(s);
keys(a, ['thread'], ['thread']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
return this.#store.all(
"SELECT target AS \"to\",at,by FROM thread_shares x WHERE thread=? AND op='share' AND seq=(SELECT max(seq) FROM thread_shares WHERE thread=x.thread AND target=x.target) ORDER BY seq",
t.id,
);
}
// Row 264a (#1769, T1 item 7): a thread's sessions, oldest first, and which one is the default.
// Each user reaches only their own threads' sessions (REQ-USER-2); there are no shares (Q7).
case 'session.list': {
@@ -5958,15 +5859,14 @@ export class Broker {
if (!this.#store.get('SELECT id FROM messages WHERE id=? AND business=?', a.id, s.business))
fail('message-not-found');
// Row 205 (design 5.7): a hidden id fails as an unknown one does. Hidden replies are left out
// in the query, so a page of them doesn't come back short. Row 206 (193b2): the threads
// #readable admits are the share exception.
// in the query, so a page of them doesn't come back short; #readable is false until 193b2,
// which adds the share exception to this clause too.
if (this.#hiddenMessage(s, a.id)) fail('message-not-found');
return this.#store.all(
`SELECT * FROM messages m WHERE business=? AND in_reply_to=? AND NOT EXISTS(SELECT 1 FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=m.id AND substr(t.owner,1,5)='user:' AND t.owner IS NOT ? AND t.id NOT IN (SELECT value FROM json_each(?))) ORDER BY seq LIMIT ${REPLIES}`,
`SELECT * FROM messages m WHERE business=? AND in_reply_to=? AND NOT EXISTS(SELECT 1 FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=m.id AND substr(t.owner,1,5)='user:' AND t.owner IS NOT ?) ORDER BY seq LIMIT ${REPLIES}`,
s.business,
a.id,
this.#owner(s) || null,
JSON.stringify(this.#readableThreads(s).map((t) => t.id)),
);
}
default:
-15
View File
@@ -1,15 +0,0 @@
-- Row 206 (193b2, #1704, row 193 design 5.2): thread shares. Only a new table, its index and its
-- append-only triggers; no existing row changes, so every thread stays private to its owner.
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
+2 -8
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'd5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9';
export const SCHEMA_VERSION = '3m';
export const SCHEMA_SHA256 = 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a';
export const SCHEMA_VERSION = '3l';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -72,11 +72,6 @@ const migrations = [{
from: '3k',
to: '3l',
sql: readFileSync(new URL('./migrations/3k-3l.sql', import.meta.url), 'utf8'),
}, {
// Row 206 (193b2, #1704): thread shares. Only a new table; no existing row changes.
from: '3l',
to: '3m',
sql: readFileSync(new URL('./migrations/3l-3m.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -92,7 +87,6 @@ const timedTables = [
'session_defaults',
'thread_claims',
'thread_opens',
'thread_shares',
'instance_names',
'usage_records',
];
@@ -17,8 +17,7 @@ const ddl3h = readFileSync(new URL('./fixtures/schema-3h.sql', import.meta.url),
const here = (p) => new URL(p, import.meta.url);
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -85,7 +84,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -93,7 +91,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -143,7 +141,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -155,14 +153,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3n';")
.replace('}];\nconst timedTables', `}, { from: '3m', to: '3n', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3m';")
.replace('}];\nconst timedTables', `}, { from: '3l', to: '3m', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3m ready/);
assert.match(migrated.stdout, /bus schema 3l ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
-480
View File
@@ -1,480 +0,0 @@
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)
, spec_path TEXT, spec_commit TEXT) 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
, to_name TEXT) 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 AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); 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 task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE 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'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE sessions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
op TEXT NOT NULL,
predecessor TEXT REFERENCES sessions(id),
UNIQUE (thread, name),
UNIQUE (thread, op)
) STRICT;
CREATE TABLE session_defaults (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
generation INTEGER NOT NULL CHECK (generation >= 1),
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 message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE INDEX message_threads_session ON message_threads (session);
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TABLE instance_names (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
AND n.instance <> NEW.instance
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TABLE hosts (
business TEXT NOT NULL,
host TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
user TEXT NOT NULL,
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
installer_fingerprint TEXT,
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
decision TEXT NOT NULL REFERENCES decisions(id),
checked_at TEXT,
PRIMARY KEY (business, host),
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
) STRICT;
CREATE TABLE host_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
host TEXT NOT NULL,
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
rotate_by TEXT NOT NULL,
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent, host),
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
) STRICT;
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
WHEN NEW.kind = 'action.allowed'
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
+6 -8
View File
@@ -34,12 +34,11 @@ function source3j(t) {
}
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -75,7 +74,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -97,7 +95,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -123,7 +121,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -134,14 +132,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+6 -8
View File
@@ -20,8 +20,7 @@ const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hos
const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -41,7 +40,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -78,7 +77,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
writeFileSync(join(moduleRoot, 'src/migrations/3i-3j.sql'), step(readFileSync(here('../src/migrations/3i-3j.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -102,7 +100,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -127,7 +125,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -138,14 +136,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+2 -3
View File
@@ -522,8 +522,7 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
assert.equal(count('table') - 1, 23);
assert.equal(count('trigger'), 89);
assert.equal(count('table') - 1, 22);
assert.equal(count('trigger'), 86);
assert.equal(count('view'), 5);
});
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3m ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3l ready', ''].join('\n'));
});
-439
View File
@@ -1,439 +0,0 @@
// Row 206 (193b2, #1704, row 193 design 5.2-5.6): a thread owner's read-only shares. The owner shares
// a thread with a user, the business human or everyone; #readable lets a sharee read it while the
// share is in force, the sharee's grant still covers it and a user owner's grant does too. Every
// write verb keeps the owner check. Mutants 2, 3, 4, 7, 8 and 9 of design section 8 die here.
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const here = (p) => new URL(p, import.meta.url);
const grant = (over = {}) => ({
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
// As user-principal.test.mjs: the access document is a function each test edits between requests.
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-thread-shares-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [] }, fail: false };
const b = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
},
access: () => {
if (access.fail) throw new Error('access file refused');
return access.doc;
},
});
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }),
tester: b.bindTester({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (who, verb, args = {}) => {
try {
call(who, verb, args);
} catch (e) {
return e.code;
}
return null;
};
const p1 = call('human', 'project.create', { name: 'shared' }).id;
const p2 = call('human', 'project.create', { name: 'private' }).id;
// alice and bob hold collaborator grants on p1, carol on p2 only.
const base = () => [grant({ projects: [p1] }), grant({ id: 'g-2', user: 'bob', projects: [p1] }), grant({ id: 'g-3', user: 'carol', projects: [p2] })];
access.doc.grants = base();
for (const name of ['alice', 'bob', 'carol']) caps[name] = b.bindUser({ business: 'demo', user: name });
const A = call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
const sent = call('alice', 'thread.send', { thread: A, body: 'alice: hello' }).id;
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
call('human', 'thread.send', { thread: H, body: 'jason: hello' });
const rows = () => store.get('SELECT count(*) n FROM thread_shares').n;
const read = (who, thread) => call(who, 'thread.messages', { thread }).map((m) => m.body);
return { b, store, access, caps, call, code, p1, p2, base, A, sent, H, rows, read };
}
test('a share lets the sharee read the thread and nothing else: every write verb stays the owner\'s (mutants 7, 9)', (t) => {
const { call, code, A, p1, rows, read } = setup(t);
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
const [listed] = call('bob', 'thread.list', { shared: true });
assert.deepEqual(Object.keys(listed), ['id', 'at', 'project', 'instance', 'name', 'owner']);
assert.deepEqual({ ...listed, at: null }, { id: A, at: null, project: p1, instance: 'assistant', name: 'alice work', owner: 'user:alice' });
// The plain list keeps its shape and holds only bob's own threads.
assert.deepEqual(call('bob', 'thread.list'), []);
assert.deepEqual(Object.keys(call('alice', 'thread.list')[0]), ['id', 'at', 'project', 'instance', 'name']);
// Mutant 7 (#threadRead for thread.send) and mutant 9 (no owner check on thread.share) die here.
for (const [verb, args] of [
['thread.send', { body: 'bob was here' }],
['thread.open', {}],
['thread.share', { to: '*' }],
['thread.share', { to: 'jason' }],
['thread.unshare', { to: 'user:bob' }],
['thread.shares', {}],
]) assert.equal(code('bob', verb, { thread: A, ...args }), 'thread-not-owned', verb);
assert.equal(rows(), 1);
assert.deepEqual(read('alice', A), ['alice: hello']);
// The share is bob's alone: the owner and carol still can't read.
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('human', 'thread.list', { shared: true }), []);
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
});
test('the business human shares too, and a `*` share reaches every covered principal but the tester', (t) => {
const { call, code, A, H, rows, read } = setup(t);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: 'user:alice' }), { thread: H, to: 'user:alice', op: 'share' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.equal(code('bob', 'thread.messages', { thread: H }), 'thread-not-owned');
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: '*' }), { thread: A, to: '*', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(read('human', A), ['alice: hello']);
assert.deepEqual(call('human', 'thread.list', { shared: true }).map((x) => x.id), [A]);
// Rule 2: carol's grant doesn't cover p1, so `*` doesn't reach her. Rule 4: nor the tester.
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('carol', 'thread.list', { shared: true }), []);
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('tester', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => [x.to, x.by]), [['*', 'user:alice']]);
assert.deepEqual(call('human', 'thread.shares', { thread: H }).map((x) => [x.to, x.by]), [['user:alice', 'jason']]);
assert.equal(rows(), 2);
});
test('mutant 2: a share never widens a grant; when the sharee\'s grant stops covering the thread, its reads end', (t) => {
const { access, call, code, base, A, p2, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
// bob keeps a collaborator grant, so the user gate passes, but it now covers p2 only.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, projects: [p2] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
// A grant on another instance is the same.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, instances: ['chief'] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
access.doc.grants = base();
assert.deepEqual(read('bob', A), ['alice: hello']);
// Removed, expired or an unreadable file: the gate refuses bob's next request.
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'bob'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('bob', 'thread.messages', { thread: A }), 'access-revoked', what);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('bob', A), ['alice: hello'], what);
}
});
test('mutant 3: a thread whose user owner has lost coverage is hidden from its sharees, the owner\'s board included', (t) => {
const { access, call, code, base, A, sent, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
assert.deepEqual(read('human', A), ['alice: hello']);
assert.ok(call('human', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
assert.ok(call('reader', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'alice'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['out of scope', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: ['none'] } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned', what);
assert.deepEqual(call('human', 'thread.list', { shared: true }), [], what);
for (const who of ['human', 'reader']) assert.deepEqual(call(who, 'trail', { subject: sent }), [], `${who} ${what}`);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('human', A), ['alice: hello'], what);
}
// A viewer grant still covers: rule 3 asks for any level.
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g));
assert.deepEqual(read('human', A), ['alice: hello']);
});
test('mutant 4: a share\'s state is its latest row, so an unshare ends it and a share after it starts it again', (t) => {
const { store, call, code, A, rows, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.equal(code('alice', 'thread.share', { thread: A, to: 'user:bob' }), 'share-exists');
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'unshare' });
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }), []);
assert.equal(code('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), 'share-not-found');
assert.equal(code('alice', 'thread.unshare', { thread: A, to: '*' }), 'share-not-found');
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => x.to), ['user:bob']);
assert.deepEqual(store.all('SELECT op,target,by FROM thread_shares ORDER BY seq').map((r) => ({ ...r })), [
{ op: 'share', target: 'user:bob', by: 'user:alice' },
{ op: 'unshare', target: 'user:bob', by: 'user:alice' },
{ op: 'share', target: 'user:bob', by: 'user:alice' },
]);
// An unshare of `*` leaves bob's own share in force.
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.unshare', { thread: A, to: '*' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(rows(), 5);
});
test('mutant 8: a user target needs a live grant of any level covering the thread, else share-not-covered', (t) => {
const { access, call, code, base, A, p1, rows } = setup(t);
// carol's grant covers p2 only; dave has none; erin's is expired.
access.doc.grants = [...base(), grant({ id: 'g-4', user: 'erin', projects: [p1], expires: '2000-01-01T00:00:00Z' })];
for (const to of ['user:carol', 'user:dave', 'user:erin'])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'share-not-covered', to);
assert.equal(rows(), 0);
// A viewer grant is enough to be shared with, though at this base it can't bind a session.
access.doc.grants = [...base(), grant({ id: 'g-5', user: 'vic', projects: [p1], level: 'viewer' })];
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'share' });
// An unshare checks no coverage: an uncovered target's share can still be ended.
access.doc.grants = base();
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'unshare' });
assert.equal(rows(), 2);
});
test('a share target is `user:<id>`, the business human or `*`, and never the sharer', (t) => {
const { call, code, A, H, rows } = setup(t);
for (const to of ['user:alice', 'bob', 'tester', 'user:tester', 'user:human', 'user:user', 'user:jason', 'user:', 'user:Bad Name', '', 42, null, ['*']])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'invalid-request', JSON.stringify(to));
for (const to of ['jason', 'user:x y'])
assert.equal(code('human', 'thread.share', { thread: H, to }), 'invalid-request', to);
assert.equal(code('alice', 'thread.share', { thread: A }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: A, to: '*', extra: 1 }), 'invalid-request');
assert.equal(code('alice', 'thread.shares', { thread: A, to: '*' }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: randomUUID(), to: '*' }), 'thread-not-found');
assert.equal(code('alice', 'thread.list', { shared: false }), 'invalid-request');
assert.equal(code('alice', 'thread.list', { shared: 'yes' }), 'invalid-request');
assert.equal(rows(), 0);
// The owner shares its own threads only; a user's thread refuses the business human too.
assert.equal(code('human', 'thread.share', { thread: A, to: '*' }), 'thread-not-owned');
assert.equal(code('alice', 'thread.share', { thread: H, to: '*' }), 'thread-not-owned');
assert.equal(rows(), 0);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: '*' }).op, 'share');
});
test('a user whose collaborator grant no longer covers its own thread can\'t share, unshare or list its shares', (t) => {
const { access, call, code, base, A, p2, rows } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: [p2] } : g));
for (const [verb, to] of [['thread.share', '*'], ['thread.unshare', 'user:bob'], ['thread.shares', undefined]])
assert.equal(code('alice', verb, { thread: A, ...(to ? { to } : {}) }), 'access-refused', verb);
assert.equal(rows(), 1);
});
test('the tester and a reader session never share: each verb refuses and writes nothing', (t) => {
const { store, call, code, A, H, rows } = setup(t);
const refusals = () => store.get("SELECT count(*) n FROM events WHERE kind='action.refused'").n;
const T = call('tester', 'thread.create', { project: call('human', 'project.list')[0].id, name: 'tester work' }).id;
const was = refusals();
for (const verb of ['thread.share', 'thread.unshare', 'thread.shares'])
for (const thread of [T, H, A]) {
assert.equal(code('tester', verb, { thread, to: '*' }), 'tester-refused', `${verb} ${thread}`);
assert.equal(code('reader', verb, { thread, to: '*' }), 'read-only', `${verb} ${thread}`);
}
// Each refusal is recorded, as every refused request is.
assert.equal(refusals() - was, 18);
assert.equal(rows(), 0);
// A share with `*` of the owner's thread doesn't reach the tester's reads.
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
test('the owner\'s trail and replies show a user\'s message only while that user\'s thread is shared with the owner', (t) => {
const { store, call, code, A, sent } = setup(t);
// The run's reply to alice, as R3's `to: 'user'` path writes it.
const reply = randomUUID();
store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
reply, new Date().toISOString(), 'demo', 'assistant', 'assistant-run', 'user', 'INFO', sent, null, null, 'hello alice',
);
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', reply, A, A);
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
call('alice', 'thread.share', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.ok(call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent), who);
assert.deepEqual(call(who, 'replies', { id: sent }).map((m) => m.id), [reply], who);
}
// The tester never reads another's thread (rule 4).
assert.deepEqual(call('tester', 'trail', { subject: sent }), []);
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('alice', 'thread.unshare', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
});
test('a share covers every session of the thread', (t) => {
const { store, call, code, H, read } = setup(t);
call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' });
const S = store.get("SELECT id FROM sessions WHERE thread=? AND name='second'", H).id;
call('human', 'thread.send', { thread: H, session: S, body: 'jason: second' });
call('human', 'thread.share', { thread: H, to: 'user:alice' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.deepEqual(call('alice', 'thread.messages', { thread: H, session: S }).map((m) => m.body), ['jason: second']);
assert.equal(code('alice', 'thread.messages', { thread: H, session: randomUUID() }), 'session-not-found');
assert.equal(code('bob', 'thread.messages', { thread: H, session: S }), 'thread-not-owned');
});
test('a sharee can\'t list the thread\'s sessions: session.list stays owner-only (Q7, sage-2818 Q1)', (t) => {
const { call, code, A, H } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('human', 'session.list', { thread: A }), 'thread-not-owned');
assert.equal(code('reader', 'session.list', { thread: A }), 'read-only');
for (const who of ['alice', 'bob']) assert.equal(code(who, 'session.list', { thread: H }), 'access-refused', who);
// The owners still list their own.
assert.equal(call('human', 'session.list', { thread: H }).length, 1);
});
test('only a human principal matches a share: an agent bound to the thread and the tester match none (sage-2818 Q2)', (t) => {
const { b, caps, call, code, A, H, sent } = setup(t);
// The assistant run serving alice's thread, and a chief run with no thread.
caps.agent = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: A });
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
for (const who of ['agent', 'chief']) call(who, 'role.claim');
const seen = (who) => call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} before`);
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.share', { thread: A, to: 'jason' });
// The human principals now read it; the agents and the tester see what they saw before.
for (const who of ['human', 'reader']) assert.equal(seen(who), true, who);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} after`);
for (const who of ['agent', 'chief']) {
assert.equal(code(who, 'thread.messages', { thread: A }), 'human-required', who);
assert.equal(code(who, 'replies', { id: sent }), 'human-required', who);
}
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
// The 3l→3m step. A 3l store from fixtures/schema-3l.sql, with one row in each thread table.
const AT = '2026-10-22T00:00:00.000Z';
const ddl3l = readFileSync(here('./fixtures/schema-3l.sql'), 'utf8');
function source3l(t) {
const root = mkdtempSync(join(tmpdir(), 'thread-shares-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3l).digest('hex'), 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a');
db.exec(ddl3l);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3l');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
for (const [id, owner] of [['t1', 'jason'], ['t2', 'user:alice']]) {
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', owner, id);
run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) VALUES(?,?,?,?,?,?,'default','legacy-3l')", AT, 'demo', id, id, 'assistant', owner);
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,'migration-3l')", AT, id, id);
}
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'm-1', AT, 'demo', 'user', 'user:alice', 'assistant', 'INFO', 'm-1');
run('INSERT INTO message_threads VALUES(?,?,?)', 'm-1', 't2', 't2');
run('INSERT INTO thread_opens(seq,at,thread,session,by) VALUES(?,?,?,?,?)', 7, AT, 't2', 't2', 'user:alice');
db.close();
chmodSync(path, 0o600);
return { root, path };
}
const open = (path, fn) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return fn(db); } finally { db.close(); }
};
const master = (path) => open(path, (db) => db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })));
// Every table's rows but meta's, whose version and digest the step changes.
const tables = (path) => open(path, (db) => Object.fromEntries(
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('meta') ORDER BY name").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all().map((r) => ({ ...r }))]),
));
const version = (path) => open(path, (db) => db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value);
test('a 3l store migrates to 3m: thread_shares is new and empty, and every other row stays as it was', (t) => {
const { root, path } = source3l(t);
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
assert.deepEqual(after, before);
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'thread-shares-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
// No existing thread is shared: each stays private to its owner.
const store = new Store(root);
t.after(() => store.close());
store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'share', 'jason', 'user:alice');
for (const [sql, args] of [
["UPDATE thread_shares SET target='*'", []],
['DELETE FROM thread_shares', []],
['INSERT OR REPLACE INTO thread_shares(seq,at,business,thread,op,target,by) VALUES(?,?,?,?,?,?,?)', [1, AT, 'demo', 't2', 'unshare', 'jason', 'user:alice']],
]) assert.throws(() => store.run(sql, ...args), /thread_shares is append-only/, sql);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'grant', '*', 'user:alice'), /CHECK/);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 'no-such', 'share', '*', 'jason'), /FOREIGN KEY/);
assert.deepEqual(store.all('SELECT seq,thread,op,target FROM thread_shares').map((r) => ({ ...r })), [{ seq: 1, thread: 't2', op: 'share', target: 'jason' }]);
});
// The 3l engine is store.mjs at this change with the 3l schema and version and no 3l→3m step.
async function engine3l(root) {
const moduleRoot = join(root, 'engine-3l');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3l);
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it was', async (t) => {
const { root, path } = source3l(t);
new Store(root, { migrateSchema: true }).close();
const was = master(path), before = tables(path);
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
const { root: old } = source3l(t);
new Store3l(old).close();
});
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+1 -3
View File
@@ -139,9 +139,7 @@ test('R3: every verb off the collaborator list refuses a user access-refused bef
]);
// R3 r6 (sage-2576): the collaborator list as ruled, written out, so a widened USER_VERBS fails here
// rather than leaving the set below with the verb it added (M3x, M6x).
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
// Row 206 (193b2): the share verbs, owner only (thread-shares.test.mjs).
'thread.share', 'thread.unshare', 'thread.shares']);
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
for (const v of USER_VERBS) others.delete(v);
assert.ok(others.size > 40);
for (const verb of others) {
+1 -1
View File
@@ -858,7 +858,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46.
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records'];
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','instance_names','usage_records'];
const LATE='2999-01-01T00:00:00.000Z';
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
+59
View File
@@ -0,0 +1,59 @@
# Mail records — schemas only (email plan row E01)
Post-v1. This package holds the record schemas for mail intake and access,
and a validator. Nothing here opens a connection, reads a secret or
registers a plug-in kind. Core `mail-source` registration waits for the
approved PRD version and the source, grant and route approvals. The addendum draft is
`docs/prd/mail-addendum-draft.md`; it binds nothing until Jason approves it.
| Schema | Record |
| --- | --- |
| `schemas/source.schema.json` | Per-source settings: enabled, adapter, `mailboxScope`, wake, limits |
| `schemas/data.schema.json` | Per-business content and retention settings |
| `schemas/access.schema.json` | A `mail.access` grant to one agent or user |
| `schemas/service.schema.json` | A source's service principal and its slot path |
| `schemas/content-key.schema.json` | A content key's location and lifecycle |
## Settings (DECISIONS Q222)
Every limit, scope and retention time is a setting. Its default and its
range sit in the schema, so they have one definition. `defaults(kind)`
reads them. Implementers and reviewers set the range; widening it is a
reviewed schema and test change. A settings record carries
`changedBy.kind: "human"`, and the validator refuses any other author.
Defaults are off and empty: `sourceEnabled: false`, `mailboxScope: []`,
`sourceMutations: []`, `accessMode: "manual"`, `unattendedWake: false`,
`wakeSenderAllowlist: []`, `persistContent: false` and
`automaticPrune: false`. `persistContent` and `automaticPrune` are fixed
false until retention authority exists (E13).
## Checks
- `validateRecord(kind, value)` returns a list of problems. It refuses
unknown keys, wildcards, secret-shaped values, values outside the
schema range and incomplete scopes. A refusal never echoes the value.
- `checkBinding({source, service, access})` refuses records that name a
different business, source or provider, and a grant past the source's
mailbox scope.
- `effectiveMailboxes(...)` intersects the source scope, the provider's
mailboxes and the grant, from `issuedAt` up to `expiresAt`. The
defaults read nothing. An invalid record or clock reads nothing.
Principal, right, manual-mode and revoke checks belong to E02.
- `checkSlotLocation` and `checkKeyLocation` keep provider slots and
content keys under `<configDir>/secrets/mosaic-stack/`, outside
dataRoot, the repository and any backup root. They compare the paths
as written and again after resolving symbolic links, so a linked
configDir or an aliased root can't hide a secret inside a forbidden root.
- `checkProtectedFile` wants a regular file under configDir, owned, mode
600, with no symbolic link from configDir itself down to the file. Given
the forbidden roots, it compares resolved paths against them too.
- `keyUse` gives active keys encrypt and decrypt, retired keys decrypt
only, and expired keys nothing. An invalid record or clock, or a time
outside `createdAt` to `expiresAt`, gives nothing.
- `keyTransitionAllowed` checks one state edge: active to retired or
expired, retired to expired. It is a pure predicate. It doesn't persist
state, stop a restored backup from rolling a record back, or destroy
key copies. E03 owns that proof.
Tests: `node --test tests/*.test.mjs` from this directory.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@mosaic/mail",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "UNLICENSED",
"exports": "./src/validate.mjs",
"scripts": {
"test": "node --test tests/*.test.mjs"
}
}
+103
View File
@@ -0,0 +1,103 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:access",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"source",
"principal",
"rights",
"mailboxes",
"mode",
"approvalId",
"issuedAt",
"expiresAt",
"revision"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-access"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"source": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"principal": {
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"enum": [
"agent",
"user"
]
},
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
}
}
},
"rights": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"uniqueItems": true,
"items": {
"enum": [
"metadata",
"body",
"attachment",
"draft"
]
}
},
"mailboxes": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]{0,253}$"
}
},
"mode": {
"enum": [
"manual",
"grant"
]
},
"approvalId": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
},
"issuedAt": {
"type": "string",
"format": "date-time"
},
"expiresAt": {
"type": "string",
"format": "date-time"
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
}
}
}
@@ -0,0 +1,57 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:content-key",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"keyId",
"path",
"state",
"createdAt",
"expiresAt",
"revision"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-content-key"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"keyId": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"path": {
"type": "string",
"pattern": "^/[^\\u0000]{0,4095}$"
},
"state": {
"enum": [
"active",
"retired",
"expired"
]
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"expiresAt": {
"type": "string",
"format": "date-time"
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
}
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:data",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"persistContent",
"bodyDraftDays",
"attachmentDays",
"metadataDays",
"automaticPrune",
"revision",
"changedBy",
"changedAt"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-data-settings"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"persistContent": {
"const": false,
"default": false
},
"bodyDraftDays": {
"type": "integer",
"minimum": 1,
"maximum": 3650,
"default": 30
},
"attachmentDays": {
"type": "integer",
"minimum": 1,
"maximum": 3650,
"default": 7
},
"metadataDays": {
"type": "integer",
"minimum": 1,
"maximum": 3650,
"default": 365
},
"automaticPrune": {
"const": false,
"default": false
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
},
"changedBy": {
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"const": "human"
},
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
}
}
},
"changedAt": {
"type": "string",
"format": "date-time"
}
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:service",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"source",
"provider",
"mailbox",
"scopes",
"slotPath",
"rotateBy",
"approvalId",
"revision"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-service"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"source": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"provider": {
"enum": [
"m365",
"imap"
]
},
"mailbox": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]{0,253}$"
},
"scopes": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"uniqueItems": true,
"items": {
"enum": [
"mail.read"
]
}
},
"slotPath": {
"type": "string",
"pattern": "^/[^\\u0000]{0,4095}$"
},
"rotateBy": {
"type": "string",
"format": "date-time"
},
"approvalId": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
}
}
}
+222
View File
@@ -0,0 +1,222 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:source",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"source",
"provider",
"sourceEnabled",
"sourceAdapter",
"mailboxScope",
"sourceMutations",
"accessMode",
"unattendedWake",
"wakeSenderAllowlist",
"limits",
"revision",
"changedBy",
"changedAt"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-source-settings"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"source": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"provider": {
"enum": [
"m365",
"imap"
]
},
"sourceEnabled": {
"type": "boolean",
"default": false
},
"sourceAdapter": {
"type": [
"string",
"null"
],
"pattern": "^[a-z][a-z0-9-]{0,63}$",
"default": null
},
"mailboxScope": {
"type": "array",
"maxItems": 100,
"uniqueItems": true,
"default": [],
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]{0,253}$"
}
},
"sourceMutations": {
"type": "array",
"maxItems": 0,
"default": [],
"items": {
"type": "string"
}
},
"accessMode": {
"enum": [
"manual",
"grant"
],
"default": "manual"
},
"unattendedWake": {
"type": "boolean",
"default": false
},
"wakeSenderAllowlist": {
"type": "array",
"maxItems": 100,
"uniqueItems": true,
"default": [],
"items": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._+-]{0,63}@[a-z0-9][a-z0-9.-]{0,252}$"
}
},
"limits": {
"type": "object",
"additionalProperties": false,
"required": [
"pollSeconds",
"sliceObservations",
"sliceBytes",
"pendingDeliveries",
"retainedBytes",
"parserInputBytes",
"mimeDepth",
"mimeParts",
"normalizedTextBytes",
"attachmentPartBytes",
"attachmentMessageBytes",
"staleScanIntervals",
"pageSize"
],
"properties": {
"pollSeconds": {
"type": "integer",
"minimum": 15,
"maximum": 3600,
"default": 60
},
"sliceObservations": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"default": 50
},
"sliceBytes": {
"type": "integer",
"minimum": 65536,
"maximum": 67108864,
"default": 10485760
},
"pendingDeliveries": {
"type": "integer",
"minimum": 1,
"maximum": 100000,
"default": 1000
},
"retainedBytes": {
"type": "integer",
"minimum": 1048576,
"maximum": 10737418240,
"default": 104857600
},
"parserInputBytes": {
"type": "integer",
"minimum": 1048576,
"maximum": 67108864,
"default": 31457280
},
"mimeDepth": {
"type": "integer",
"minimum": 1,
"maximum": 32,
"default": 10
},
"mimeParts": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"default": 100
},
"normalizedTextBytes": {
"type": "integer",
"minimum": 4096,
"maximum": 4194304,
"default": 262144
},
"attachmentPartBytes": {
"type": "integer",
"minimum": 0,
"maximum": 52428800,
"default": 10485760
},
"attachmentMessageBytes": {
"type": "integer",
"minimum": 0,
"maximum": 104857600,
"default": 10485760
},
"staleScanIntervals": {
"type": "integer",
"minimum": 2,
"maximum": 20,
"default": 3
},
"pageSize": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
}
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
},
"changedBy": {
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"const": "human"
},
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
}
}
},
"changedAt": {
"type": "string",
"format": "date-time"
}
}
}
+237
View File
@@ -0,0 +1,237 @@
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from 'node:path';
// Mail record schemas (email plan row E01). Nothing here reads a secret, opens a connection or
// registers a plug-in kind: core mail-source registration waits for the approved PRD version and the
// source, grant and route approvals.
export const KINDS = Object.freeze(['source', 'data', 'access', 'service', 'content-key']);
const KEYWORDS = new Set(['$schema', '$id', 'type', 'const', 'enum', 'properties', 'required',
'additionalProperties', 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength',
'minimum', 'maximum', 'pattern', 'format', 'default']);
const schemas = Object.fromEntries(KINDS.map(kind => [kind,
JSON.parse(readFileSync(new URL(`../schemas/${kind}.schema.json`, import.meta.url), 'utf8')),
]));
// Package-owned schemas use this deliberately small closed subset. Never silently ignore a new constraint.
function known(schema) {
for (const key of Object.keys(schema)) if (!KEYWORDS.has(key)) throw Error(`unsupported schema keyword: ${key}`);
for (const child of Object.values(schema.properties ?? {})) known(child);
if (schema.items) known(schema.items);
}
for (const schema of Object.values(schemas)) known(schema);
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
function visit(schema, value, path, problems) {
const bad = text => problems.push(`${path}: ${text}`);
if (Object.hasOwn(schema, 'const') && value !== schema.const) bad(`must equal ${JSON.stringify(schema.const)}`);
if (schema.enum && !schema.enum.includes(value)) bad(`must be one of ${schema.enum.join(', ')}`);
const types = schema.type ? [schema.type].flat() : [];
const matches = type => type === 'null' ? value === null : type === 'object' ? isObject(value)
: type === 'array' ? Array.isArray(value) : type === 'integer' ? Number.isSafeInteger(value)
: typeof value === type;
if (types.length && !types.some(matches)) { bad(`must be ${types.join(' or ')}`); return; }
if (typeof value === 'number') {
if (schema.minimum !== undefined && value < schema.minimum) bad(`below the schema bound ${schema.minimum}`);
if (schema.maximum !== undefined && value > schema.maximum) bad(`above the schema bound ${schema.maximum}`);
}
if (typeof value === 'string') {
if (schema.minLength !== undefined && value.length < schema.minLength) bad('too short');
if (schema.maxLength !== undefined && value.length > schema.maxLength) bad('too long');
if (schema.pattern && !new RegExp(schema.pattern).test(value)) bad('invalid format');
if (schema.format === 'date-time' && (!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value || value.length !== 24)) bad('invalid canonical UTC date-time');
if (value.includes('\0')) bad('NUL is forbidden');
}
if (Array.isArray(value)) {
if (schema.minItems !== undefined && value.length < schema.minItems) bad('too few items');
if (schema.maxItems !== undefined && value.length > schema.maxItems) bad('too many items');
if (schema.uniqueItems && new Set(value.map(item => JSON.stringify(item))).size !== value.length) bad('duplicate items');
if (schema.items) value.forEach((item, index) => visit(schema.items, item, `${path}[${index}]`, problems));
}
if (isObject(value)) {
for (const key of schema.required ?? []) if (!Object.hasOwn(value, key)) bad(`missing ${key}`);
for (const [key, child] of Object.entries(value)) {
if (Object.hasOwn(schema.properties ?? {}, key)) visit(schema.properties[key], child, `${path}.${key}`, problems);
else if (schema.additionalProperties === false) bad(`unknown key ${key}`);
}
}
}
// Wildcards are refused by every pattern; this names the reason plainly. Scopes are exact IDs only.
const WILDCARD = /[*?%]/;
// Shapes of secret values. A record names a protected slot path, never a credential (invariant 3).
const SECRET_SHAPES = [
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
/\b(?:gh[pousr]_|glpat-|xox[abprs]-|sk-)[A-Za-z0-9_-]{8,}/,
/:\/\/[^/\s:@]+:[^/\s@]+@/,
/\b(?:password|passwd|secret|token|apikey|api_key|client_secret)\s*[=:]/i,
];
export const looksSecret = text => SECRET_SHAPES.some(shape => shape.test(text));
function strings(value, path, out) {
if (typeof value === 'string') out.push([path, value]);
else if (Array.isArray(value)) value.forEach((item, index) => strings(item, `${path}[${index}]`, out));
else if (isObject(value)) for (const [key, child] of Object.entries(value)) strings(child, `${path}.${key}`, out);
return out;
}
export function validateRecord(kind, value) {
if (!Object.hasOwn(schemas, kind)) return [`unknown record kind: ${kind}`];
const problems = [];
visit(schemas[kind], value, kind, problems);
for (const [path, text] of strings(value, kind, [])) {
if (looksSecret(text)) problems.push(`${path}: looks like a secret value; name a protected slot instead`);
else if (WILDCARD.test(text)) problems.push(`${path}: wildcard not allowed; scopes name exact IDs`);
}
if (problems.length) return problems;
if (kind === 'source') {
if (value.sourceEnabled && value.sourceAdapter === null)
problems.push('source.sourceAdapter: an enabled source needs an enrolled adapter');
if (value.unattendedWake && value.wakeSenderAllowlist.length === 0)
problems.push('source.wakeSenderAllowlist: unattended wake needs at least one exact sender');
if (value.limits.attachmentPartBytes > value.limits.attachmentMessageBytes)
problems.push('source.limits: attachmentPartBytes exceeds attachmentMessageBytes');
}
if (kind === 'data' && value.metadataDays < Math.max(value.bodyDraftDays, value.attachmentDays))
problems.push('data.metadataDays: metadata and tombstones must outlive content');
if (kind === 'access' && Date.parse(value.expiresAt) <= Date.parse(value.issuedAt))
problems.push('access.expiresAt: must be after issuedAt');
if (kind === 'content-key' && Date.parse(value.expiresAt) <= Date.parse(value.createdAt))
problems.push('content-key.expiresAt: must be after createdAt');
return problems;
}
// Defaults come from the schema, so a default and its bounds have one definition (Q222).
function defaultsOf(schema) {
if (Object.hasOwn(schema, 'default')) return structuredClone(schema.default);
if (schema.type !== 'object') return undefined;
const out = {};
for (const [key, child] of Object.entries(schema.properties ?? {})) {
const value = defaultsOf(child);
if (value !== undefined) out[key] = value;
}
return Object.keys(out).length ? out : undefined;
}
export function defaults(kind) {
if (kind !== 'source' && kind !== 'data') throw Error(`no settings defaults for ${kind}`);
return defaultsOf(schemas[kind]);
}
export const schemaBounds = (kind, key) => {
const node = key.split('.').reduce((schema, part) => schema?.properties?.[part], schemas[kind]);
if (!node || node.minimum === undefined) throw Error(`no bounds for ${kind}.${key}`);
return { minimum: node.minimum, maximum: node.maximum, default: node.default };
};
// Records that name one another must agree, and a grant never reaches past the source's mailbox scope.
// (The access schema already limits a grant's principal to an agent or a user, never the service.)
export function checkBinding({ source, service, access }) {
const problems = [];
const same = (label, a, b) => { for (const key of ['business', 'source']) if (a[key] !== b[key]) problems.push(`${label}: ${key} mismatch`); };
if (service) {
same('service', service, source);
if (service.provider !== source.provider) problems.push('service: provider mismatch');
}
if (access) {
same('access', access, source);
const scope = new Set(source.mailboxScope);
for (const mailbox of access.mailboxes) if (!scope.has(mailbox)) problems.push(`access: mailbox ${mailbox} outside the source scope`);
}
return problems;
}
const instant = now => (now instanceof Date && Number.isFinite(now.getTime()) ? now.getTime() : NaN);
// What a principal may read: the source scope, the provider's mailboxes and the grant, intersected.
// A disabled source, the default empty scope, a missing, invalid, not-yet-issued or expired grant, or an
// invalid clock reads nothing. Authenticated principal, right, manual-mode and revoke checks belong to E02.
export function effectiveMailboxes({ source, providerMailboxes = [], access = null, now = new Date() }) {
if (!access || validateRecord('source', source).length || validateRecord('access', access).length) return [];
if (!source.sourceEnabled || !Array.isArray(providerMailboxes)) return [];
const at = instant(now);
if (!(at >= Date.parse(access.issuedAt) && at < Date.parse(access.expiresAt))) return [];
if (access.business !== source.business || access.source !== source.source) return [];
const provider = new Set(providerMailboxes);
const granted = new Set(access.mailboxes);
return source.mailboxScope.filter(mailbox => provider.has(mailbox) && granted.has(mailbox));
}
function inside(root, path) {
const rel = relative(root, path);
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
}
// The path with every existing part resolved, so a symbolic link or alias can't hide where it lands.
// Parts that don't exist yet (a planned slot) are kept as written under their nearest existing ancestor.
function canonical(path) {
const rest = [];
for (let at = path; ; at = dirname(at)) {
try { return join(realpathSync(at), ...rest.reverse()); } catch (error) {
if (error.code !== 'ENOENT' || at === dirname(at)) return null;
rest.push(basename(at));
}
}
}
// Lexical checks first, for planned paths. Then the same forbidden roots compared after resolution, so
// a configDir that links into dataRoot, the repository or a backup root is refused too.
function forbidden(label, path, { dataRoot, repoRoot, backupRoots = [] }) {
const problems = [];
const roots = [['dataRoot', dataRoot], ['the repository', repoRoot], ...backupRoots.map(root => ['a backup root', root])]
.filter(([, root]) => root);
for (const [name, root] of roots) if (inside(root, path)) problems.push(`${label}: inside ${name}; mail secrets live outside it`);
if (problems.length) return problems;
const real = canonical(path);
if (real === null) return [`${label}: path can't be resolved`];
for (const [name, root] of roots) {
const realRoot = canonical(root);
if (realRoot === null || inside(realRoot, real)) problems.push(`${label}: resolves inside ${name}; mail secrets live outside it`);
}
return problems;
}
function placed(label, path, expectedDir, roots) {
if (!isAbsolute(path) || normalize(path) !== path) return [`${label}: must be a normalized absolute path`];
const problems = !inside(expectedDir, path) || path === expectedDir ? [`${label}: must sit under ${expectedDir}`] : [];
return [...problems, ...forbidden(label, path, roots)];
}
// A source service's provider credential slot (plan D9).
export const slotDir = ({ configDir, business, source }) => join(configDir, 'secrets', 'mosaic-stack', 'mail', business, source);
export const checkSlotLocation = (service, roots) =>
placed('service.slotPath', service.slotPath, slotDir({ ...roots, ...service }), roots);
// A content key, outside dataRoot and its backup set, apart from provider slots (plan D7).
export const keyPath = ({ configDir, business, keyId }) => join(configDir, 'secrets', 'mosaic-stack', 'mail-content', business, keyId);
export function checkKeyLocation(record, roots) {
const problems = placed('content-key.path', record.path, join(roots.configDir, 'secrets', 'mosaic-stack', 'mail-content', record.business), roots);
if (!problems.length && record.path !== keyPath({ ...roots, ...record })) problems.push(`content-key.path: must be ${keyPath({ ...roots, ...record })}`);
return problems;
}
// A protected file: inside configDir, owned, mode 600, regular, with no symbolic link from configDir
// itself down to the file, and not resolving into a forbidden root.
export function checkProtectedFile(path, { configDir, uid = process.getuid(), ...roots }) {
if (!isAbsolute(path) || normalize(path) !== path || !isAbsolute(configDir) || normalize(configDir) !== configDir)
return [`${path}: must be a normalized absolute path`];
if (!inside(configDir, path) || path === configDir) return [`${path}: must sit under ${configDir}`];
let stat;
try { stat = lstatSync(path); } catch { return [`${path}: not found`]; }
if (stat.isSymbolicLink() || !stat.isFile()) return [`${path}: must be a regular file, not a symbolic link`];
for (let at = dirname(path); ; at = dirname(at)) {
let dir;
try { dir = lstatSync(at); } catch { return [`${path}: path can't be resolved`]; }
if (dir.isSymbolicLink()) return [`${path}: a symbolic link sits on the path at ${at}`];
if (at === configDir) break;
}
const problems = [];
if (stat.uid !== uid) problems.push(`${path}: owned by uid ${stat.uid}, not ${uid}`);
if ((stat.mode & 0o777) !== 0o600) problems.push(`${path}: mode ${(stat.mode & 0o777).toString(8)}; use 600`);
return [...problems, ...forbidden(path, path, roots)];
}
// Key lifecycle states: active keys encrypt and decrypt, retired keys only decrypt, expired keys do
// neither. keyTransitionAllowed is a pure check on one state edge. It doesn't persist state, stop a
// restored backup from rolling a record back, or destroy key copies; E03 owns that proof.
const KEY_STEPS = { active: ['retired', 'expired'], retired: ['expired'], expired: [] };
export const keyTransitionAllowed = (from, to) => Object.hasOwn(KEY_STEPS, from) && KEY_STEPS[from].includes(to);
// The use a record allows now. An invalid record or clock, or a time outside [createdAt, expiresAt), is none.
export function keyUse(record, now = new Date()) {
if (validateRecord('content-key', record).length) return 'none';
const at = instant(now);
if (!(at >= Date.parse(record.createdAt) && at < Date.parse(record.expiresAt))) return 'none';
return record.state === 'active' ? 'encrypt-decrypt' : record.state === 'retired' ? 'decrypt' : 'none';
}
+337
View File
@@ -0,0 +1,337 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
checkBinding, checkKeyLocation, checkProtectedFile, checkSlotLocation, defaults, effectiveMailboxes, keyPath,
keyTransitionAllowed, keyUse, schemaBounds, slotDir, validateRecord,
} from '../src/validate.mjs';
// Synthetic records only. No credential, mailbox or provider is real, and nothing here opens a connection.
const HUMAN = { kind: 'human', id: 'jason' };
const AT = '2026-10-22T16:00:00.000Z';
const source = (over = {}) => ({
recordVersion: 1, kind: 'mail-source-settings', business: 'acme', source: 'ingress', provider: 'imap',
...defaults('source'), revision: 1, changedBy: HUMAN, changedAt: AT, ...over,
});
const data = (over = {}) => ({
recordVersion: 1, kind: 'mail-data-settings', business: 'acme', ...defaults('data'),
revision: 1, changedBy: HUMAN, changedAt: AT, ...over,
});
const access = (over = {}) => ({
recordVersion: 1, kind: 'mail-access', business: 'acme', source: 'ingress',
principal: { kind: 'agent', id: 'mail-specialist' }, rights: ['metadata', 'body'],
mailboxes: ['[email protected]'], mode: 'manual', approvalId: 'decision-1',
issuedAt: AT, expiresAt: '2026-10-29T16:00:00.000Z', revision: 1, ...over,
});
const CONFIG = '/home/op/.config/mosaic-acme';
const ROOTS = { configDir: CONFIG, dataRoot: '/home/op/.mosaic-acme', repoRoot: '/src/stack', backupRoots: ['/backup/mosaic'] };
const service = (over = {}) => ({
recordVersion: 1, kind: 'mail-service', business: 'acme', source: 'ingress', provider: 'imap',
mailbox: '[email protected]', scopes: ['mail.read'],
slotPath: `${CONFIG}/secrets/mosaic-stack/mail/acme/ingress/imap`, rotateBy: '2027-01-01T00:00:00.000Z',
approvalId: 'decision-2', revision: 1, ...over,
});
const contentKey = (over = {}) => ({
recordVersion: 1, kind: 'mail-content-key', business: 'acme', keyId: 'k1',
path: `${CONFIG}/secrets/mosaic-stack/mail-content/acme/k1`, state: 'active',
createdAt: AT, expiresAt: '2027-10-22T16:00:00.000Z', revision: 1, ...over,
});
const refused = (kind, value, text) => {
const problems = validateRecord(kind, value);
assert.ok(problems.some(problem => problem.includes(text)), `${kind} should be refused with "${text}": ${JSON.stringify(problems)}`);
};
test('T1 the defaults are off, empty and valid, and every record kind validates', () => {
const settings = defaults('source');
assert.equal(settings.sourceEnabled, false);
assert.deepEqual(settings.mailboxScope, []);
assert.deepEqual(settings.sourceMutations, []);
assert.equal(settings.sourceAdapter, null);
assert.equal(settings.accessMode, 'manual');
assert.equal(settings.unattendedWake, false);
assert.deepEqual(settings.wakeSenderAllowlist, []);
assert.deepEqual(settings.limits, {
pollSeconds: 60, sliceObservations: 50, sliceBytes: 10485760, pendingDeliveries: 1000, retainedBytes: 104857600,
parserInputBytes: 31457280, mimeDepth: 10, mimeParts: 100, normalizedTextBytes: 262144,
attachmentPartBytes: 10485760, attachmentMessageBytes: 10485760, staleScanIntervals: 3, pageSize: 20,
});
assert.deepEqual(defaults('data'), { persistContent: false, bodyDraftDays: 30, attachmentDays: 7, metadataDays: 365, automaticPrune: false });
for (const [kind, value] of [['source', source()], ['data', data()], ['access', access()], ['service', service()], ['content-key', contentKey()]])
assert.deepEqual(validateRecord(kind, value), [], kind);
assert.throws(() => defaults('access'), /no settings defaults/);
assert.deepEqual(validateRecord('mailbox', {}), ['unknown record kind: mailbox']);
});
test('T2 the default scope reads nothing, and a read is the intersection of scope, provider and grant', () => {
const grant = access({ mailboxes: ['[email protected]'] });
const providerMailboxes = ['[email protected]', '[email protected]'];
assert.deepEqual(effectiveMailboxes({ source: source(), providerMailboxes, access: grant }), []);
const enabled = source({ sourceEnabled: true, sourceAdapter: 'imap' });
assert.deepEqual(effectiveMailboxes({ source: enabled, providerMailboxes, access: grant }), [], 'enabled with the default empty scope');
const scoped = source({ sourceEnabled: true, sourceAdapter: 'imap', mailboxScope: ['[email protected]', '[email protected]'] });
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date(AT) }), ['[email protected]']);
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: null }), [], 'no grant');
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes: [], access: grant, now: new Date(AT) }), [], 'provider denies');
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date('2026-10-29T16:00:00.000Z') }), [], 'expired grant');
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: access({ source: 'other' }), now: new Date(AT) }), [], 'grant for another source');
assert.deepEqual(effectiveMailboxes({ source: { ...scoped, sourceEnabled: false }, providerMailboxes, access: grant, now: new Date(AT) }), [], 'disabled');
// Lifetime edges: the grant reads from issuedAt up to, not including, expiresAt.
const read = (over = {}) => effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date(AT), ...over });
assert.deepEqual(read({ now: new Date('2026-10-22T15:59:59.999Z') }), [], 'before issuedAt');
assert.deepEqual(read({ now: new Date('2026-10-29T15:59:59.999Z') }), ['[email protected]'], 'the last instant before expiresAt');
// Malformed records or clock fail closed.
assert.deepEqual(read({ now: new Date(Number.NaN) }), [], 'invalid clock');
assert.deepEqual(read({ now: AT }), [], 'a clock that is not a Date');
assert.deepEqual(read({ access: access({ expiresAt: 'not-a-date' }) }), [], 'invalid expiresAt');
assert.deepEqual(read({ access: access({ issuedAt: 'not-a-date' }) }), [], 'invalid issuedAt');
assert.deepEqual(read({ access: access({ issuedAt: '2026-10-23T16:00:00.000Z' }) }), [], 'issued in the future');
const { mailboxes, ...noMailboxes } = grant;
assert.deepEqual(read({ access: noMailboxes }), [], 'grant without mailboxes');
assert.deepEqual(read({ source: { ...scoped, mailboxScope: ['*'] } }), [], 'invalid source');
assert.deepEqual(read({ providerMailboxes: '[email protected]' }), [], 'provider list that is not an array');
});
test('T3 only a human changes settings', () => {
for (const kind of ['source', 'data']) {
const make = kind === 'source' ? source : data;
refused(kind, make({ changedBy: { kind: 'agent', id: 'mail-specialist' } }), 'changedBy.kind: must equal "human"');
refused(kind, make({ changedBy: { kind: 'system', id: 'mail' } }), 'changedBy.kind: must equal "human"');
const { changedBy, ...unsigned } = make();
refused(kind, unsigned, 'missing changedBy');
}
});
test('T4 unknown fields are refused at every level', () => {
refused('source', source({ secretRef: 'x' }), 'unknown key secretRef');
refused('source', source({ limits: { ...defaults('source').limits, unlimited: 1 } }), 'unknown key unlimited');
refused('data', data({ purgeNow: true }), 'unknown key purgeNow');
refused('access', access({ principal: { kind: 'agent', id: 'a', asHuman: true } }), 'unknown key asHuman');
refused('service', service({ password: 'x' }), 'unknown key password');
refused('content-key', contentKey({ material: 'x' }), 'unknown key material');
});
test('T5 wildcards are refused in scopes, grants, allowlists and principals', () => {
refused('source', source({ mailboxScope: ['*'] }), 'wildcard not allowed');
refused('source', source({ mailboxScope: ['%'] }), 'wildcard not allowed');
refused('source', source({ wakeSenderAllowlist: ['*@example.test'] }), 'wildcard not allowed');
refused('access', access({ mailboxes: ['[email protected]'] }), 'wildcard not allowed');
refused('access', access({ principal: { kind: 'agent', id: '*' } }), 'wildcard not allowed');
refused('service', service({ mailbox: '*' }), 'wildcard not allowed');
});
test('T6 secret values are refused wherever they appear', () => {
const sentinels = [
'-----BEGIN OPENSSH PRIVATE KEY-----', 'eyJhbGciOiJub25lIn0.eyJzdWIiOiJzZW50aW5lbCJ9.x',
'ghp_SENTINELSENTINEL', 'imaps://user:[email protected]', 'password=SENTINEL', 'client_secret: SENTINEL',
];
for (const sentinel of sentinels) {
refused('service', service({ slotPath: `${CONFIG}/secrets/mosaic-stack/mail/acme/ingress/${sentinel}` }), 'looks like a secret value');
refused('service', service({ approvalId: sentinel }), 'looks like a secret value');
refused('access', access({ approvalId: sentinel }), 'looks like a secret value');
}
const problems = validateRecord('service', service({ approvalId: 'password=SENTINEL' }));
assert.ok(!problems.join('\n').includes('SENTINEL'), 'a refusal never echoes the value');
});
test('T7 incomplete scopes are refused', () => {
refused('access', access({ rights: [] }), 'access.rights: too few items');
refused('access', access({ mailboxes: [] }), 'access.mailboxes: too few items');
refused('access', access({ rights: ['metadata', 'send'] }), 'must be one of');
refused('access', access({ rights: ['body', 'body'] }), 'duplicate items');
refused('access', access({ expiresAt: AT }), 'must be after issuedAt');
refused('service', service({ scopes: [] }), 'service.scopes: too few items');
refused('service', service({ scopes: ['mail.send'] }), 'must be one of');
refused('source', source({ sourceEnabled: true }), 'an enabled source needs an enrolled adapter');
refused('source', source({ unattendedWake: true }), 'unattended wake needs at least one exact sender');
refused('source', source({ sourceMutations: ['mark-read'] }), 'too many items');
refused('source', source({ mailboxScope: ['[email protected]', '[email protected]'] }), 'duplicate items');
assert.deepEqual(validateRecord('source', source({ unattendedWake: true, wakeSenderAllowlist: ['[email protected]'] })), []);
});
test('T8 mismatched principal and source are refused', () => {
const scoped = source({ mailboxScope: ['[email protected]'] });
assert.deepEqual(checkBinding({ source: scoped, service: service(), access: access() }), []);
assert.deepEqual(checkBinding({ source: scoped, service: service({ source: 'other' }) }), ['service: source mismatch']);
assert.deepEqual(checkBinding({ source: scoped, service: service({ business: 'beta' }) }), ['service: business mismatch']);
assert.deepEqual(checkBinding({ source: scoped, service: service({ provider: 'm365' }) }), ['service: provider mismatch']);
assert.deepEqual(checkBinding({ source: scoped, access: access({ business: 'beta' }) }), ['access: business mismatch']);
assert.deepEqual(checkBinding({ source: scoped, access: access({ mailboxes: ['[email protected]'] }) }),
['access: mailbox [email protected] outside the source scope']);
assert.deepEqual(checkBinding({ source: source(), access: access() }), ['access: mailbox [email protected] outside the source scope'],
'the default empty scope admits no grant');
refused('access', access({ principal: { kind: 'service', id: 'mail' } }), 'must be one of agent, user');
assert.deepEqual(checkSlotLocation(service({ slotPath: `${CONFIG}/secrets/mosaic-stack/mail/acme/other/imap` }), ROOTS),
[`service.slotPath: must sit under ${CONFIG}/secrets/mosaic-stack/mail/acme/ingress`]);
});
test('T9 settings take changed values inside the schema bounds and refuse values outside them', () => {
for (const key of ['pollSeconds', 'parserInputBytes', 'mimeDepth', 'mimeParts', 'normalizedTextBytes', 'pageSize']) {
const { minimum, maximum } = schemaBounds('source', `limits.${key}`);
const at = value => source({ limits: { ...defaults('source').limits, [key]: value } });
assert.deepEqual(validateRecord('source', at(minimum)), [], `${key} at its minimum`);
assert.deepEqual(validateRecord('source', at(maximum)), [], `${key} at its maximum`);
refused('source', at(minimum - 1), 'below the schema bound');
refused('source', at(maximum + 1), 'above the schema bound');
refused('source', at(minimum + 0.5), 'must be integer');
}
assert.equal(schemaBounds('source', 'limits.pageSize').maximum, 100);
refused('source', source({ limits: { ...defaults('source').limits, attachmentPartBytes: 20971520 } }), 'attachmentPartBytes exceeds attachmentMessageBytes');
assert.deepEqual(validateRecord('data', data({ bodyDraftDays: 90, metadataDays: 400 })), []);
refused('data', data({ metadataDays: 10 }), 'metadata and tombstones must outlive content');
refused('data', data({ persistContent: true }), 'persistContent: must equal false');
refused('data', data({ automaticPrune: true }), 'automaticPrune: must equal false');
});
test('T10 slot and key locations stay outside dataRoot, the repository and backups', () => {
assert.equal(slotDir({ configDir: CONFIG, business: 'acme', source: 'ingress' }), `${CONFIG}/secrets/mosaic-stack/mail/acme/ingress`);
assert.deepEqual(checkSlotLocation(service(), ROOTS), []);
assert.deepEqual(checkKeyLocation(contentKey(), ROOTS), []);
const nested = { ...ROOTS, dataRoot: CONFIG };
assert.ok(checkSlotLocation(service(), nested).some(problem => problem.includes('inside dataRoot')));
assert.ok(checkKeyLocation(contentKey(), nested).some(problem => problem.includes('inside dataRoot')));
assert.ok(checkKeyLocation(contentKey(), { ...ROOTS, backupRoots: ['/home/op/.config'] }).some(problem => problem.includes('inside a backup root')));
assert.ok(checkKeyLocation(contentKey(), { ...ROOTS, repoRoot: CONFIG }).some(problem => problem.includes('inside the repository')));
assert.deepEqual(checkKeyLocation(contentKey({ path: '/home/op/.mosaic-acme/mail/acme/k1' }), ROOTS),
[`content-key.path: must sit under ${CONFIG}/secrets/mosaic-stack/mail-content/acme`, 'content-key.path: inside dataRoot; mail secrets live outside it']);
assert.deepEqual(checkKeyLocation(contentKey({ path: `${CONFIG}/secrets/mosaic-stack/mail-content/acme/k2` }), ROOTS),
[`content-key.path: must be ${keyPath({ configDir: CONFIG, business: 'acme', keyId: 'k1' })}`]);
assert.ok(checkKeyLocation(contentKey({ path: `${CONFIG}/secrets/mosaic-stack/mail/acme/k1` }), ROOTS)[0].includes('must sit under'), 'apart from provider slots');
assert.deepEqual(checkKeyLocation(contentKey({ path: `${CONFIG}/secrets/mosaic-stack/mail-content/acme/../acme/k1` }), ROOTS),
['content-key.path: must be a normalized absolute path']);
});
test('T10b a symbolic link or alias can\'t move a slot or key into a forbidden root', () => {
const root = mkdtempSync(join(tmpdir(), 'mail-place-'));
try {
const dataRoot = join(root, 'data');
const roots = { dataRoot, repoRoot: join(root, 'repo'), backupRoots: [join(root, 'backup')] };
// configDir is a symbolic link into dataRoot, with a real 0600 key behind it.
mkdirSync(join(dataRoot, 'cfg'), { recursive: true, mode: 0o700 });
const configDir = join(root, 'config');
symlinkSync(join(dataRoot, 'cfg'), configDir);
const dir = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'acme');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = join(dir, 'k1');
writeFileSync(file, 'synthetic', { mode: 0o600 });
const key = contentKey({ path: file });
assert.deepEqual(checkKeyLocation(key, { ...roots, configDir }), ['content-key.path: resolves inside dataRoot; mail secrets live outside it']);
// A planned slot that doesn't exist yet resolves through its nearest existing ancestor.
const slot = service({ slotPath: join(configDir, 'secrets', 'mosaic-stack', 'mail', 'acme', 'ingress', 'imap') });
assert.deepEqual(checkSlotLocation(slot, { ...roots, configDir }), ['service.slotPath: resolves inside dataRoot; mail secrets live outside it']);
// A forbidden root named through an alias still covers the real configDir.
const realConfig = join(dataRoot, 'cfg');
const alias = join(root, 'alias');
symlinkSync(dataRoot, alias);
const realKey = contentKey({ path: join(realConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1') });
assert.deepEqual(checkKeyLocation(realKey, { ...roots, dataRoot: alias, configDir: realConfig }),
['content-key.path: resolves inside dataRoot; mail secrets live outside it']);
// The same layout outside every forbidden root passes.
const safeConfig = join(root, 'safe');
mkdirSync(safeConfig, { mode: 0o700 });
const safeKey = contentKey({ path: join(safeConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1') });
assert.deepEqual(checkKeyLocation(safeKey, { ...roots, configDir: safeConfig }), []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('T11 a protected file must be a regular owned 600 file with no symbolic link on its path', () => {
const root = mkdtempSync(join(tmpdir(), 'mail-schema-'));
try {
const configDir = join(root, 'config');
const dir = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'acme');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = join(dir, 'k1');
writeFileSync(file, 'synthetic', { mode: 0o600 });
assert.deepEqual(checkProtectedFile(file, { configDir }), []);
chmodSync(file, 0o640);
assert.ok(checkProtectedFile(file, { configDir }).some(problem => problem.includes('mode 640')));
chmodSync(file, 0o400);
assert.ok(checkProtectedFile(file, { configDir }).some(problem => problem.includes('mode 400')));
chmodSync(file, 0o600);
assert.ok(checkProtectedFile(file, { configDir, uid: process.getuid() + 1 }).some(problem => problem.includes('owned by uid')));
const link = join(dir, 'k2');
symlinkSync(file, link);
assert.deepEqual(checkProtectedFile(link, { configDir }), [`${link}: must be a regular file, not a symbolic link`]);
const realDir = join(root, 'elsewhere');
mkdirSync(realDir, { mode: 0o700 });
writeFileSync(join(realDir, 'k3'), 'synthetic', { mode: 0o600 });
symlinkSync(realDir, join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta'));
const viaLink = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta', 'k3');
assert.deepEqual(checkProtectedFile(viaLink, { configDir }),
[`${viaLink}: a symbolic link sits on the path at ${join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta')}`]);
assert.deepEqual(checkProtectedFile(join(dir, 'missing'), { configDir }), [`${join(dir, 'missing')}: not found`]);
mkdirSync(join(dir, 'k4'));
assert.deepEqual(checkProtectedFile(join(dir, 'k4'), { configDir }), [`${join(dir, 'k4')}: must be a regular file, not a symbolic link`]);
// The file must sit under configDir, lexically.
const outside = join(realDir, 'k3');
assert.deepEqual(checkProtectedFile(outside, { configDir }), [`${outside}: must sit under ${configDir}`]);
const dotted = `${configDir}/../elsewhere/k3`;
assert.deepEqual(checkProtectedFile(dotted, { configDir }), [`${dotted}: must be a normalized absolute path`]);
// A symbolic-link configDir is refused, even with a real 0600 file behind it.
const linkedConfig = join(root, 'linked-config');
symlinkSync(configDir, linkedConfig);
const viaConfig = join(linkedConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1');
assert.deepEqual(checkProtectedFile(viaConfig, { configDir: linkedConfig }), [`${viaConfig}: a symbolic link sits on the path at ${linkedConfig}`]);
// A forbidden root named through an alias still covers the file.
const alias = join(root, 'alias');
symlinkSync(root, alias);
assert.deepEqual(checkProtectedFile(file, { configDir, dataRoot: alias }), [`${file}: resolves inside dataRoot; mail secrets live outside it`]);
assert.deepEqual(checkProtectedFile(file, { configDir, dataRoot: join(root, 'data') }), []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('T12 the key lifecycle moves forward only, and an expired key gives no use', () => {
assert.ok(keyTransitionAllowed('active', 'retired'));
assert.ok(keyTransitionAllowed('active', 'expired'));
assert.ok(keyTransitionAllowed('retired', 'expired'));
for (const [from, to] of [['retired', 'active'], ['expired', 'active'], ['expired', 'retired'], ['active', 'active'], ['lost', 'active'], ['constructor', 'active']])
assert.equal(keyTransitionAllowed(from, to), false, `${from} -> ${to}`);
const now = new Date('2027-01-01T00:00:00.000Z');
assert.equal(keyUse(contentKey(), now), 'encrypt-decrypt');
assert.equal(keyUse(contentKey({ state: 'retired' }), now), 'decrypt');
assert.equal(keyUse(contentKey({ state: 'expired' }), now), 'none');
assert.equal(keyUse(contentKey(), new Date('2027-10-22T16:00:00.000Z')), 'none', 'past expiresAt, whatever the state says');
assert.equal(keyUse(contentKey({ state: 'retired' }), new Date('2027-10-22T15:59:59.999Z')), 'decrypt', 'the last instant before expiresAt');
assert.equal(keyUse(contentKey(), new Date(AT)), 'encrypt-decrypt', 'from createdAt');
assert.equal(keyUse(contentKey(), new Date('2026-10-22T15:59:59.999Z')), 'none', 'before createdAt');
// Malformed records or clock fail closed.
assert.equal(keyUse(contentKey({ state: 'escrowed' }), now), 'none', 'unknown state');
assert.equal(keyUse(contentKey({ state: 'constructor' }), now), 'none', 'prototype key as state');
assert.equal(keyUse(contentKey({ createdAt: 'not-a-date' }), now), 'none', 'invalid createdAt');
assert.equal(keyUse(contentKey({ expiresAt: 'not-a-date' }), now), 'none', 'invalid expiresAt');
assert.equal(keyUse(contentKey(), new Date(Number.NaN)), 'none', 'invalid clock');
assert.equal(keyUse(contentKey(), '2027-01-01T00:00:00.000Z'), 'none', 'a clock that is not a Date');
refused('content-key', contentKey({ state: 'escrowed' }), 'must be one of');
refused('content-key', contentKey({ expiresAt: AT }), 'must be after createdAt');
});
test('T13 the disabled schema leaves v1 unchanged: no mail kind, no mail action', () => {
const kinds = readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8');
const vocabulary = readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8');
assert.ok(!/mail/i.test(kinds), 'core mail-source registration waits for the approved PRD version and the source, grant and route approvals');
assert.ok(!/mail\.access/.test(vocabulary), 'mail.access waits for a Jason-approved PRD version');
const prd = readFileSync(new URL('../../../docs/prd/mosaic-stack.md', import.meta.url), 'utf8');
assert.ok(!/REQ-MAIL-/.test(prd), 'the addendum is a draft until Jason approves it');
});
test('T14 every addendum requirement maps to build rows, and every row E01-E16 is covered', () => {
const draft = readFileSync(new URL('../../../docs/prd/mail-addendum-draft.md', import.meta.url), 'utf8');
assert.match(draft, /^- Status: draft, not approved\./m);
const blocks = draft.split(/^- \*\*(?=REQ-MAIL-)/m).slice(1);
const ids = blocks.map(block => block.match(/^(REQ-MAIL-\d+)\.\*\*/)[1]);
assert.deepEqual(ids, Array.from({ length: ids.length }, (_, index) => `REQ-MAIL-${index + 1}`));
assert.equal((draft.match(/\*\*REQ-MAIL-/g) ?? []).length, ids.length);
const covered = new Set();
for (const [index, block] of blocks.entries()) {
const rows = block.match(/^ {2}Rows: (E\d\d(?:, E\d\d)*)\.$/m);
assert.ok(rows, `${ids[index]} names its rows`);
for (const row of rows[1].split(', ')) covered.add(row);
}
const all = Array.from({ length: 16 }, (_, index) => `E${String(index + 1).padStart(2, '0')}`);
assert.deepEqual([...covered].sort(), all);
});
+1 -1
View File
@@ -417,7 +417,7 @@ plugin_checks() {
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records"
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens instance_names usage_records"
STORE_JS='
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");