Compare commits

..
7 changed files with 247 additions and 318 deletions

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
# Row214 C2-11a work record
Assignment sage-2794 at2026-10-22T16:33:20Z, Q220/mos-1301, issue#1715.
New isolated branch herb/row214 on aca16404c. The earlier197/215 worktrees
and their dirty/untracked historical packets are untouched. node_modules is
the required plain symlink to /mnt/storage/src/mosaic-stack/node_modules.
Read C2-11a in both pinned briefs. The section is unchanged at51cc0445c;
Q222 changes C2-06's reply-limit settings, not wikilinks. The editor model,
entry, view, existing model/browser/provenance tests, build/toolchain and
vendored markdown parser/serializer were read. No third_party source changes.
## Static finding and authored work
model.mjs stores a canonical sig and the raw original block, and serialise
returns raw when the sig matches. The vendored text writer escapes brackets
(to_markdown.ts:433-437). It is therefore possible for no-edit round-trips
to pass while dirty-block output escapes a wikilink. This is a source-based
hypothesis, not a measured result. Both outcomes are in DESIGN.md.
Authored, unrun: the pinned-bundle measurement; ten fixture round-trips;
three browser regressions; inert wikilink schema/tokenizer/serializer. The
schema includes headings, and front matter remains raw. The serializer
validates its spelling before bypassing escaping. HTML/entity-like targets
stay text under the existing escaping rules. No clicking or resolution.
Local static git diff --check is clean. No Node, npm, compiler, browser,
measurement, test, Docker, HTTPS Git or push has run. The queue claim waits
for a gap because scripts/mosaic invokes Node. No candidate or pass claim.
Only design/path documents may be committed before the granted red/green
checks; product, tests and measurement remain uncommitted until validated.
## Next granted steps
1. Claim from canonical during a CLEAR/gap.
2. Run measure.mjs against aca16404c's pinned Git blobs; save JSON unchanged.
3. Run the structural/edit regression alone on the baseline bundle (red).
4. Install the already-pinned esbuild0.28.2 in this worktree's packages/webui
with npm ci --ignore-scripts --no-audit --no-fund; no root install or lock edit.
5. Build with the existing editor/build.mjs, then the whole new test file and
existing editor, editor-browser and editor-provenance files, each singly.
6. Kill the serializer-removal mutant, restore, rebuild, focused green.
7. Commit only after the applicable checks, push only when Sage clears HTTPS
Git, send Filbert and Mos the candidate SHA/hash manifest directly, cc Sage.
8. After253 lands, new branch on its tip preserving all landed changes.
9. Filbert and Sage's whole webui and scripts gate finish the row.
All runs wait for explicit Sage CLEAR. Gander's row253 run5 owns the slot.
The whole suite gate and any unexpected failure use mos-0823's stop rule.
Proposed SESSIONS text for Sage's post-253 doc sweep (do not append here):
2026-10-22 | Herb (gpt-6.1-sol, f6cf4287) | row214 C2-11a editor wikilinks authoring | On herb/row214 at aca16404c, authored baseline round-trip measurement, designs for equal/unequal results, inert inline node and regression tests under sage-2794/Q220. Claim, runs and pushes held while253 owns the slot; measurement and test results unavailable.
## Filbert static design/path review accepted, R1 and R2 required
Review at2026-10-22T16:43:35Z read dee9f3d8d's packet only, no product or
runs. R1 is now in the plan: set MOSAIC_REQUIRE_EDITOR_BUILD=1 on focused
provenance and whole webui runs, grep ^# skip plus TAP/spec skip directives,
and name every skip. Missing/wrong pinned esbuild must fail, not skip.
R2: baseline no-edit roundTrips are expected to pass. They test retention
of raw bytes and are not red evidence for the node. Only the baseline
same-block structural/edit regression is expected red.
Two added fixtures cover a reference-definition collision and an atom
inside ordinary link text, taking the planned file to10 no-edit tests and
3 browser tests. The explicit & exclusion preserves the existing escaped
HTML/entity writer for dirty blocks; Q&A stays text and is a known limit.
Measurement VM setup matches loadEditor. Browser TMPDIR must use the short
non-/tmp scratch root. Sweep Node/Chromium after mutant/timeout and at clear.
All remain authored and unrun while253 owns the slot.
+128
View File
@@ -0,0 +1,128 @@
# C2-11a: an inert editor wikilink node
Base: aca16404c. Owner: Herb. Reviewer: Filbert. Issue: #1715.
Authority: sage-2794, Q220/mos-1301. The assignment permits authoring the
node and tests before the measurement slot. Runs, claim and pushes wait
for Sage's CLEAR. After row253 lands, a new branch will carry the change
onto its landing with every landed hunk retained.
## Measurement before choosing the explanation
Run agents/herb/work/queue-214/measure.mjs only on a granted slot. It loads
aca16404c's editor.js and markdown-preview.js directly from local Git
objects. It verifies the editor bundle hash against that base's BUILD.json.
It cannot accidentally measure a candidate rebuild. Its JSON records the
full baseline SHA, bundle hash, timestamp, input, output and equality for
each fixture. The first five are the brief's plain, labelled, heading,
list and front-matter cases; extra cases cover a heading block, combined
heading/label, Unicode/spacing, adjacency and an inert embed. No result is
available yet. Static reasoning is not a measurement.
Outcome A, every baseline round-trip is equal: record that wikilinks do
not trigger Source fallback for the measured inputs. Do not claim that
bug exists. The source's raw/sig cache explains why an unchanged block can
keep its bytes even when its canonical serializer escapes brackets. Add
the node for structured wikilinks and dirty-block preservation, as the
brief requests. A baseline structural/edit regression must fail even if
all unchanged-file checks pass.
Outcome B, one or more differ: record the exact differing bytes and affected
contexts. Keep the existing Source fallback fail-closed. The node restores
the required spellings where the block is structured. Retain the same
no-edit and dirty-block regressions. If the mismatch comes from a separate
block, line-ending or front-matter defect, report it before widening scope.
## Representation and conversion
The schema gains one inline atom, wikilink, with a single source attribute.
Its source is the complete spelling, not reconstructed target/label parts.
It has no href, navigation, fetch or click behavior. A span with data-source
and a text child displays the spelling. Heading content includes the new
atom; paragraphs, quotes and lists already accept inline content.
One markdown-it inline rule runs before ordinary links, after escapes and
backticks. A sticky match reads a complete single-line [[...]] or ![[...]]
without copying every suffix or crossing state.posMax. The node writer
writes its validated spelling without bracket escaping, so editing adjacent
text cannot rewrite a wikilink. Front matter remains a raw block. No new
file-read, save, routing, documentPath or target-resolution behavior is added.
The accepted atom grammar excludes brackets within the body, line breaks,
angle brackets and ampersands. This is an editor representation boundary,
not a library resolver or a refusal to open such a file. Malformed,
HTML/entity-like and code syntax stays under the existing text/raw rules.
This preserves the existing escaping of HTML/entities when a block changes.
DOM paste accepts only that same grammar. Invalid attributes constructed
programmatically are flattened to one line and escaped by the text writer.
No input string is parsed as HTML and embeds never load an image.
## Acceptance and review
The measurement JSON goes in BUILD.md and the final manifest packet.
Ten fixture round-trips run with and without a final newline. A browser
test inspects real ProseMirror atom types and attributes, edits the same
block and reparses it, for each fixture. Two more browser tests cover
code/escape/malformed/hostile syntax and DOM/programmatic-attribute safety.
Tests use the existing fixture and Chromium harness and the shipped bundle.
They do not export new production APIs just for testing.
Before rebuilding, run only the structural regression against the baseline
bundle and observe it fail. Rebuild the candidate with the exact pinned
esbuild0.28.2 binary, run the whole new test file, existing editor tests,
editor browser tests and provenance test. Remove only the wikilink serializer
handler for one mutant; the dirty-block assertion must fail. Restore, rebuild
and rerun the focused regression. All execution needs Sage's CLEAR, including
installation, checks, builds, browser tests and the measurement.
The checked-in bundle and editor/BUILD.json must be regenerated by the
existing build script. No hand edits of generated JavaScript or provenance.
The current shared esbuild is0.28.1 and the local webui install is absent.
A granted npm ci --ignore-scripts --no-audit --no-fund in packages/webui is
needed before building; the root node_modules remains its plain symlink.
No dependency or lockfile change is planned.
Final gate: Filbert approves the manifest, then the whole webui suite and
every scripts/test-*.sh on Sage's slot. Two failures stop a gate. A failure
outside this row is reported to Sage before any rerun. Source, generated
output, mode/hash manifest and raw granted logs identify the candidate.
## Static review follow-through (Filbert, 2026-10-22T16:43:35Z)
R1: the focused editor-provenance run and Sage's whole webui gate must set
MOSAIC_REQUIRE_EDITOR_BUILD=1. A missing, wrong-version or unpinned binary
then fails. Check every log for ^# skip, as well as TAP # SKIP directives
and the spec reporter's skipped-test lines; record each skipped name and
reason. A skipped rebuild is not proof of bundle reproducibility. The CSS
output must stay byte-identical and match BUILD.json.
R2: no-edit roundTrip checks are expected to pass on the baseline because
parse sets sig=canonical and an unchanged serialise returns lead+raw. Their
green result is compatibility evidence, never evidence that the atom was
needed or installed. The same-block edit/structural regression is the red
that proves the new node. The required measurement still runs and is kept.
N1: bus.js checks visual mode at949 on this base and also requires
MosaicMarkdown. The measurement uses the existing helper's bare-window VM
shape, markdown-preview.js first, then editor.js, both from pinned Git blobs.
N2: the exclusion of & and angle brackets is deliberate: the existing
text serializer escapes entities/HTML when it rewrites a block for another
renderer. An atom's verbatim writer must not bypass that rule, including
for pasted/programmatic values. This keeps those targets as ordinary text;
[[Q&A]] will still be escaped if its own block changes. It is a known
representation limit beyond the five brief cases, not a resolver change.
It is included among the text-only cases. Unedited bytes remain intact.
N3: the reference-definition fixture [[a]] with [a]: u deliberately becomes
one inert wikilink rather than a bracket around a reference link. Literal
spelling and the separate definition block remain intact.
N4: [x [[a]]](u) is an explicit fixture. The atom inherits the enclosing
ordinary link mark, whose existing target gate still applies. The atom
itself supplies no href or new click behavior; the fixture checks its
structure and spelling before/after a same-block edit.
N6: every granted browser/test process gets TMPDIR from
mktemp -d /mnt/storage/scratch/tmp/gXXX. Never /tmp. Sweep owned Chromium
and Node processes by cwd and the temp-root path after the mutant, after
any timeout, and before clearing the slot. Retain/report uncertain owners.
+23
View File
@@ -0,0 +1,23 @@
# Row214 implementation manifest, draft before measurement
Pinned base: aca16404c. Brief: docs/plans/2026-10-20_case2-rows.md,
C2-11a. Also read at gosalyn/case2-06-idea-caps51cc0445c; that section is
unchanged. Q222's reply-limit settings belong to C2-06, not this row.
| Path | Change |
| --- | --- |
| packages/webui/editor/model.mjs | inert inline atom, heading content, tokenizer rule and serializer |
| packages/webui/tests/editor-wikilink.test.mjs | ten round-trips and three browser regressions |
| packages/webui/tests/helpers/wikilink-cases.mjs | exact fixtures shared with measurement |
| packages/webui/src/public/editor.js | generated by the pinned editor build, pending CLEAR |
| packages/webui/editor/BUILD.json | generated provenance, pending CLEAR |
| agents/herb/work/queue-214/measure.mjs | baseline Git-object measurement, authored and unrun |
| agents/herb/work/queue-214/DESIGN.md | both measurement outcomes, implementation and acceptance |
| agents/herb/work/queue-214/BUILD.md | rulings and receipts, pending runs |
| agents/herb/work/queue-214/MANIFEST.md | this reviewed path list |
editor.css is an unchanged build output; no source CSS is touched. No
entry/view, library, route, save, click, documentPath or dependency changes.
Generated changes do not exist yet. This is a design/path manifest, not a
frozen tested candidate. Candidate hashes and measurement results follow
only after the granted runs. Filbert reviews the plan before the build.
-90
View File
@@ -1,90 +0,0 @@
# Row 275 (193b1b, #1780): viewer verbs and board refusals
Morgana, 2026-10-22, for Duckworth (reviewer) and Sage (gate owner). Brief: docs/plans/2026-10-20_human-users-build.md,
"Brief: 193b1b, viewer verbs and board refusals" (blob de3a6310b). Design: row 193 DESIGN.md r5 e2d161e2e
(morgana/row193), sections 4.3, 8 and 9. Base: refactor aca16404c, branch morgana/row275. Assignment: sage-2788.
## What the code did before
- `#userGrants` (broker.mjs, FED R3) kept only grants that cover at level `collaborator` (`USER_LEVEL`).
A viewer-only user was refused `access-revoked` at `bindUser` and on every request.
- `#granted` and `#grantedThread` asked `collaborator` for every user verb, reads included.
- The reader session's board list was an inline array at the request gate.
## The change
1. `VIEWER_VERBS = ['project.list', 'thread.list', 'thread.messages', 'names']`, exported. `userLevel(verb)`
gives `viewer` for these and `collaborator` for the other user verbs (thread.create, thread.open,
thread.send). `USER_LEVEL` is gone.
2. `#userGrants` keeps a live grant at any level (`covers(..., {level: 'viewer'})`), so a viewer binds and
passes the request gate. The verb check against `USER_VERBS` is unchanged and still comes first.
3. `#granted(s, verb, project, instance)` and `#grantedThread(s, verb, t)` take the verb and ask
`userLevel(verb)`. Each handler passes its own verb name. A viewer gets `access-refused` on create,
open and send, the code design 4.3 names.
4. `BOARD_VERBS` exports the reader list unchanged (inbox, agents, thread.claims, tasks, trail, decisions,
credentials, replies, messages, state, usage), and the reader gate uses it. None is in `USER_VERBS`, so
every user is refused them `access-refused`. The test reads the same constant and pins it written out.
Downgrade path (design 4.3 note 2): a collaborator lowered to viewer still lists and reads its own threads
and can't open or send. The runtime's user transport calls `bindUser`, so a viewer can now bind over the
socket too. That is the intended effect; the verbs it reaches are the four above.
Not in this row: the share verbs, `thread.list {shared: true}` and `#readable`'s share rules (193b2, row 206);
the revocation timer, `access.reload` and the level-loss test (FED R4, which mutant 5 moved to). FED R4 has
not landed; this row checks the level per request only, which is what R3 already reads per request.
## Existing test changes (mos-1311 ruling)
Mos approved both changes in mos-1311 (16:44:56Z), under mos-1272. They follow row 193 DESIGN r5
(e2d161e2e, approved by Duckworth) section 4.3: the thread.list row at DESIGN.md:125 ("own threads still
covered (from an earlier collaborator grant)") and note 2 at :139 ("A viewer can still read its own
threads when a collaborator grant is lowered to viewer, but can't send or open them"). Condition 3: Duckworth
confirms in review that both match section 4.3.
All in `packages/bus/tests/user-principal.test.mjs` (FED R3, row 274):
| Test | Assertion | Old expectation | New expectation | Design line |
|---|---|---|---|---|
| "R3: bindUser binds a granted user and refuses one with no live grant" (title was "...no live collaborator grant") | `bindUser` with `grant({ level: 'viewer' })` | refused `access-revoked` (case `'viewer level'` in the refused list) | binds: `assert.ok(user('alice'), 'viewer level')`; the case leaves the refused list | :125 viewer column; 4.3 viewer verbs need a bound viewer |
| "R3: a removed or expired grant refuses the next request access-revoked..." | `thread.list` after `level: 'viewer'` ('downgraded') | `access-revoked` | `null` (answers) | :125 and :139 |
| same test, added (mos-1311 condition 1) | `thread.send` and `thread.open` on A after the downgrade | (not asserted) | each `access-refused`; `messages` and `thread_opens` counts unchanged | :139 "can't send or open them" |
The header comment of the file is updated to say a viewer grant binds. Every other R3 refusal (no grant,
another user, another business, expired, unreadable file, no file, removed, file refused) is unchanged.
No other test names a viewer grant on the bus (git grep for `level: 'viewer'` outside packages/business).
## Tests, packages/bus/tests/viewer.test.mjs (new)
1. VIEWER_VERBS is the ruled list and sits inside USER_VERBS.
2. A viewer binds; project.list, names, thread.list and thread.messages answer what its grant covers
(alice downgraded, bob viewer from the start).
3. A viewer is refused thread.create, thread.open and thread.send `access-refused`, and messages, threads
and thread_opens counts are unchanged. Kills mutant 6 at alice's send. r3 (duckworth-0166 B1): bob is
asked create only, as `#thread` refuses a non-owner `thread-not-owned` before the level check; open and
send are asked on alice's own thread. Bob stays the viewer who never held a collaborator grant.
4. Every verb off USER_VERBS refuses a viewer `access-refused` before its own checks.
5. Mixed grants: collaborator on project a, viewer on b. Create in a works, create in b refuses; send in b's
old thread refuses, open refuses, messages reads the message sent while collaborator (r3, N1).
6. Board reads (BOARD_VERBS, the project file verbs, limits, plugins, instances, routes) refuse alice
(downgraded viewer), bob (viewer) and carol (collaborator) `access-refused`. Kills mutant 10.
7. Governance pin, user side: one row per item of the 5.7 sentence (decisions, inbox entries, claims as
`agents` and `thread.claims`, tasks as `tasks` and `state`, project files as `project.files` and
`project.file`), each refused to a collaborator and a viewer while a run in carol's thread has raised a
decision the owner sees; carol still reads her own message with thread.messages. The owner side stays
in message-filter.test.mjs (row 205).
## Mutants (design 8)
| # | Mutation | Killed by |
|---|---|---|
| 6 | `'thread.send'` added to `VIEWER_VERBS` | viewer.test 3 (send answers); test 1 pin fails too |
| 10 | `'trail'` added to `USER_VERBS` | viewer.test 6 (trail no longer access-refused); R3's exact USER_VERBS pin fails too |
The kill rule for each mutant names the behaviour test above, not the pin.
## Gate (brief)
Duckworth approves the manifest. On Sage's gate: the touched test files whole (viewer, user-principal,
message-filter), the bus suite and every `scripts/test-*.sh` green, mutants 6 and 10 killed. broker.mjs
is imported by the cli, harness, business and seat suites as well; I'll propose them for the gate as in
row 277 (sage-2590). Nothing has run yet: built during Gander's 253 slot with no node.
+20 -30
View File
@@ -132,15 +132,8 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
// 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.*.
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
// Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They
// read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a
// collaborator grant on the project and instance, so a viewer gets access-refused on each.
export const VIEWER_VERBS = Object.freeze(['project.list', 'thread.list', 'thread.messages', 'names']);
// The least level a user verb needs (R1's covers, morgana-0005; LEVELS runs from most to least).
const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collaborator');
// Row 275 (row 193 design 4.3): the owner's board reads, which a reader session may call. A user,
// collaborator or viewer, is refused every one at the USER_VERBS gate.
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
// 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
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -771,8 +764,7 @@ export class Broker {
return s.tester ? 'tester' : s.user ? 'user:' + s.user : s.human;
}
// FED R3 (design 3.5): the user's live grants in the session's business, from one read of the
// access file. Any failure to read leaves none. Row 275: a grant at any level counts here; each
// verb's own check asks the level it needs (#granted).
// access file. Any failure to read leaves none.
#userGrants(s) {
let doc = null;
try {
@@ -782,22 +774,21 @@ export class Broker {
}
const now = new Date();
return (doc?.grants ?? []).filter((g) => g.user === s.user && g.business === s.business &&
covers(g, { business: s.business, project: g.projects[0], instance: g.instances[0] }, now, { level: 'viewer' }));
covers(g, { business: s.business, project: g.projects[0], instance: g.instances[0] }, now, { level: USER_LEVEL }));
}
// Does one of the request's grants cover a thread of `instance` in `project`, at the level `verb`
// needs? A null `instance` asks about the project alone, a null `project` about the instance alone.
#granted(s, verb, project, instance) {
// Does one of the request's grants cover a thread of `instance` in `project`? A null `instance`
// asks about the project alone, a null `project` about the instance alone.
#granted(s, project, instance) {
const now = new Date();
return s.grants.some((g) => covers(g, {
business: s.business,
project: project ?? g.projects[0],
instance: instance ?? g.instances[0],
}, now, { level: userLevel(verb) }));
}, now, { level: USER_LEVEL }));
}
// A thread the request's grants still cover at the level `verb` needs, else access-refused
// (design 3.5, thread.open/send/messages; row 193 design 4.3 for the level).
#grantedThread(s, verb, t) {
if (s.user && !this.#granted(s, verb, t.project, t.instance)) fail('access-refused');
// A thread the request's grants still cover, else access-refused (design 3.5, thread.open/send/messages).
#grantedThread(s, t) {
if (s.user && !this.#granted(s, t.project, t.instance)) fail('access-refused');
return t;
}
// Row 205 (193b1a, #1703, row 193 design 5.7): a message in a thread a user other than the caller
@@ -1020,8 +1011,7 @@ export class Broker {
}
// FED R3 (#414, design 3.6): the trusted user transport invokes this only after row 196's
// verifyPrincipal checked the user CLI and its Console parent. It binds only a user the access
// file gives a live grant in the business (check 3), else access-revoked. Row 275: a viewer grant
// binds too; the verbs it reaches are VIEWER_VERBS. A user id
// file gives a live collaborator grant in the business (check 3), else access-revoked. A user id
// is never the bus's own principals' or the business human's, whatever the file says.
bindUser(record) {
keys(record, ['business', 'user'], ['business', 'user']);
@@ -4236,7 +4226,7 @@ export class Broker {
return result;
}
return this.#store.transaction(() => {
if (s.reader && !BOARD_VERBS.includes(request.verb))
if (s.reader && !['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage'].includes(request.verb))
fail('read-only');
if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused');
// A tester reads the human's views, as a reader does, and its message verbs are its own.
@@ -5689,7 +5679,7 @@ export class Broker {
return this.#store.all(
'SELECT instance,name,at FROM instance_names n WHERE business=? AND seq=(SELECT max(seq) FROM instance_names WHERE business=n.business AND instance=n.instance) ORDER BY instance',
s.business,
).filter((r) => !s.user || this.#granted(s, 'names', null, r.instance));
).filter((r) => !s.user || this.#granted(s, null, r.instance));
}
// Row 121 (#1615): projects group the user's threads (REQ-ORG-2). `repo` names a
// project from the business file; the rename to repos is a P19 follow-up.
@@ -5719,7 +5709,7 @@ export class Broker {
// FED R3 (design 3.5): a user lists granted projects only.
return this.#store
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
.filter((p) => !s.user || this.#granted(s, p.id, null));
}
// A thread belongs to the user who made it and to the business's one thread instance.
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
@@ -5731,7 +5721,7 @@ export class Broker {
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
if (s.user) {
const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
if (threaded.length !== 1 || !this.#granted(s, identifier(a.project), threaded[0])) fail('access-refused');
}
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
fail('project-not-found');
@@ -5769,14 +5759,14 @@ export class Broker {
s.business,
this.#owner(s),
...(a.project === undefined ? [] : [a.project]),
).filter((t) => !s.user || this.#granted(s, 'thread.list', t.project, t.instance));
).filter((t) => !s.user || this.#granted(s, t.project, t.instance));
}
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1). Row 264a: a thread
// verb names one session of the thread, or works on its default (D3).
case 'thread.open': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session'], ['thread']);
const t = this.#grantedThread(s, 'thread.open', this.#thread(s, a.thread));
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const session = this.#threadSession(t, a.session);
this.#store.run('INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)', this.#now(), t.id, session, this.#owner(s));
return { opened: t.id };
@@ -5786,7 +5776,7 @@ export class Broker {
case 'thread.send': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session', 'body', 'class'], ['thread', 'body']);
const t = this.#grantedThread(s, 'thread.send', this.#thread(s, a.thread));
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const session = this.#threadSession(t, a.session);
// Row 120's lists hold here as on message.send: the instance must hear from the human.
this.#checkMessaging(s.business, 'human', t.instance);
@@ -5815,7 +5805,7 @@ export class Broker {
case 'thread.messages': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session', 'after', 'limit'], ['thread']);
const t = this.#grantedThread(s, 'thread.messages', this.#thread(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');
+6 -16
View File
@@ -1,7 +1,6 @@
// FED R3 (#414, federation design 3.5, 3.6 and the R3 row of section 7): the broker's user principal.
// A user binds only with a live grant (row 275: a viewer grant binds too; viewer.test.mjs has the
// viewer cases), reaches the exact collaborator verb list, sees only what its grants cover, and
// talks to the granted instance in its own threads. The access
// A user binds only with a live collaborator grant, reaches the exact collaborator verb list, sees
// only what its grants cover, and talks to the granted instance in its own threads. The access
// document comes in as a function, as the runtime passes R1's loadAccess; each test changes it.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -84,17 +83,15 @@ function granted(t, options) {
return { ...ctx, p1, p2, A };
}
test('R3: bindUser binds a granted user and refuses one with no live grant', (t) => {
test('R3: bindUser binds a granted user and refuses one with no live collaborator grant', (t) => {
const { b, access, user } = setup(t);
assert.ok(user('alice'));
// Row 275 (193b1b): a viewer grant binds; it used to refuse here as the case 'viewer level'.
access.doc.grants = [grant({ level: 'viewer' })];
assert.ok(user('alice'), 'viewer level');
for (const [what, grants] of [
['no grant', []],
['another user', [grant({ user: 'bob' })]],
['another business', [grant({ business: 'other' })]],
['expired', [grant({ expires: '2000-01-01T00:00:00Z' })]],
['viewer level', [grant({ level: 'viewer' })]],
]) {
access.doc.grants = grants;
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, what);
@@ -300,7 +297,7 @@ test('R3: a user thread run cannot message the owner, while its uncited gated ra
});
test('R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request', (t) => {
const { call, code, access, store, A } = granted(t);
const { call, code, access } = granted(t);
const reads = access.reads;
call('alice', 'thread.list');
call('alice', 'project.list');
@@ -310,15 +307,8 @@ test('R3: a removed or expired grant refuses the next request access-revoked, an
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'removed');
access.doc.grants = kept.map((g) => ({ ...g, expires: '2000-01-01T00:00:00Z' }));
assert.equal(code('alice', 'project.list'), 'access-revoked', 'expired');
// Row 275 (193b1b, design 4.3): a grant lowered to viewer still reads the user's own threads.
access.doc.grants = kept.map((g) => ({ ...g, level: 'viewer' }));
assert.equal(code('alice', 'thread.list'), null, 'downgraded');
// mos-1311 condition 1: the downgraded viewer still can't send or open, and nothing is stored.
const stored = () => ['messages', 'thread_opens'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n);
const before = stored();
assert.equal(code('alice', 'thread.send', { thread: A, body: 'still mine?' }), 'access-refused', 'downgraded send');
assert.equal(code('alice', 'thread.open', { thread: A }), 'access-refused', 'downgraded open');
assert.deepEqual(stored(), before, 'downgraded: nothing stored');
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'downgraded');
access.fail = true;
access.doc.grants = kept;
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'file refused');
-182
View File
@@ -1,182 +0,0 @@
// Row 275 (193b1b, #1780, row 193 design 4.3, 8 and 9): the viewer level on the bus. A viewer grant
// binds and reaches VIEWER_VERBS only; thread.create, thread.open and thread.send need a collaborator
// grant on the project and instance. Board reads refuse every user, collaborator or viewer, and the
// user-side governance pin names each item of the 5.7 user sentence. The fixture is user-principal's.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import {
Broker, ACTIONS, USER_VERBS, VIEWER_VERBS, BOARD_VERBS, TESTER_VERBS, TESTER_ORGANIZATION_VERBS, PROJECT_FILE_VERBS,
} from '../src/broker.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const grant = (over = {}) => ({
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-viewer-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [grant()] } };
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: () => access.doc,
});
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
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 user = (name) => (caps[name] = b.bindUser({ business: 'demo', user: name }));
const launch = (name, thread) => {
caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
return call(name, 'role.claim');
};
return { b, store, access, caps, call, code, user, launch };
}
// Two projects. Alice, a collaborator on p1, made thread A there and sent one message; then her grant
// is lowered to viewer (the downgrade path, design 4.3). Bob is a viewer on p1 from the start.
function viewers(t) {
const ctx = setup(t);
const p1 = ctx.call('human', 'project.create', { name: 'shared' }).id;
const p2 = ctx.call('human', 'project.create', { name: 'private' }).id;
ctx.access.doc.grants = [grant({ projects: [p1] })];
ctx.user('alice');
const A = ctx.call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
const sent = ctx.call('alice', 'thread.send', { thread: A, body: 'hello' }).id;
ctx.access.doc.grants = [
grant({ projects: [p1], level: 'viewer' }),
grant({ id: 'g-b', user: 'bob', projects: [p1], level: 'viewer' }),
];
ctx.user('bob');
return { ...ctx, p1, p2, A, sent };
}
test('275: VIEWER_VERBS is the ruled read list, inside USER_VERBS', () => {
assert.deepEqual(VIEWER_VERBS, ['project.list', 'thread.list', 'thread.messages', 'names']);
for (const v of VIEWER_VERBS) assert.ok(USER_VERBS.includes(v), v);
});
test('275: a viewer binds, and each viewer verb answers with what its grant covers', (t) => {
const { call, p1, A, sent } = viewers(t);
call('human', 'name.set', { instance: 'assistant', name: 'ada' });
call('human', 'name.set', { instance: 'chief', name: 'cleo' });
for (const who of ['alice', 'bob']) {
assert.deepEqual(call(who, 'project.list').map((p) => p.id), [p1], who);
assert.deepEqual(call(who, 'names').map((n) => n.instance), ['assistant'], who);
}
// Alice's own old thread still lists and reads; bob has none.
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]);
assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent]);
assert.deepEqual(call('bob', 'thread.list'), []);
});
// Mutant 6 (row 193 design 8): a viewer allowed thread.send. This test kills it.
test('275: a viewer is refused thread.create, thread.open and thread.send access-refused, and nothing is stored', (t) => {
const { store, code, p1, A } = viewers(t);
const counts = () => ['messages', 'threads', 'thread_opens'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n);
const before = counts();
for (const who of ['alice', 'bob'])
assert.equal(code(who, 'thread.create', { project: p1, name: `${who} new` }), 'access-refused', `${who} create`);
// Only alice owns a thread: #thread refuses a non-owner thread-not-owned before the level check
// (duckworth-0166 B1), so open and send are asked on her own A.
assert.equal(code('alice', 'thread.open', { thread: A }), 'access-refused', 'alice open');
assert.equal(code('alice', 'thread.send', { thread: A, body: 'still here?' }), 'access-refused', 'alice send');
assert.deepEqual(counts(), before);
});
test('275: every verb off the user list refuses a viewer access-refused before its own checks', (t) => {
const { b, caps } = viewers(t);
const others = new Set([...ACTIONS, ...TESTER_VERBS, ...TESTER_ORGANIZATION_VERBS, ...PROJECT_FILE_VERBS, ...BOARD_VERBS, 'no.such.verb']);
for (const v of USER_VERBS) others.delete(v);
assert.ok(others.size > 40);
for (const verb of others)
assert.throws(() => b.request(caps.bob, { verb, args: { junk: true } }), { code: 'access-refused' }, verb);
});
test('275: mixed grants, collaborator on one project and viewer on another, take the level per project', (t) => {
const { call, code, access, user } = setup(t);
const pa = call('human', 'project.create', { name: 'a' }).id;
const pb = call('human', 'project.create', { name: 'b' }).id;
access.doc.grants = [grant({ projects: [pa] }), grant({ id: 'g-2', projects: [pb] })];
user('alice');
const B = call('alice', 'thread.create', { project: pb, name: 'b work' }).id;
const inB = call('alice', 'thread.send', { thread: B, body: 'in b, before' }).id;
access.doc.grants = [grant({ projects: [pa] }), grant({ id: 'g-2', projects: [pb], level: 'viewer' })];
const A = call('alice', 'thread.create', { project: pa, name: 'a work' }).id;
assert.ok(A, 'create in the collaborator project works');
assert.equal(code('alice', 'thread.create', { project: pb, name: 'b more' }), 'access-refused', 'create in the viewer project');
assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [pa, pb]);
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [B, A]);
assert.ok(call('alice', 'thread.send', { thread: A, body: 'in a' }).id);
assert.equal(code('alice', 'thread.send', { thread: B, body: 'in b' }), 'access-refused');
assert.equal(code('alice', 'thread.open', { thread: B }), 'access-refused');
assert.deepEqual(call('alice', 'thread.messages', { thread: B }).map((m) => m.id), [inB]);
});
// Mutant 10 (row 193 design 8): a user allowed `trail`. This test kills it; R3's exact USER_VERBS
// pin in user-principal.test.mjs fails too. Carol is a collaborator, so the refusal isn't the level's.
test('275: board reads refuse users and viewers access-refused', (t) => {
const { code, access, user, p1, A } = viewers(t);
assert.deepEqual(BOARD_VERBS, ['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
access.doc.grants = [...access.doc.grants, grant({ id: 'g-c', user: 'carol', projects: [p1] })];
user('carol');
for (const who of ['alice', 'bob', 'carol']) {
for (const verb of [...BOARD_VERBS, ...PROJECT_FILE_VERBS, 'limits', 'plugins', 'instances', 'routes'])
assert.equal(code(who, verb, verb === 'trail' ? { subject: A } : {}), 'access-refused', `${who} ${verb}`);
}
});
// Row 193's user sentence, from the user's side (mos-1023 item 2, design 8): "The business owner sees
// your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your
// messages." The owner side is message-filter.test.mjs's pin. Here each item the owner sees is a
// board read the user is refused, at either level, while the user still reads its own thread.
test('governance pin, user side: decisions, inbox entries, claims, tasks and project files are the owner\'s, the user reads its messages', (t) => {
const { call, code, access, user, launch, p1 } = viewers(t);
access.doc.grants = [...access.doc.grants, grant({ id: 'g-c', user: 'carol', projects: [p1] })];
user('carol');
const C = call('carol', 'thread.create', { project: p1, name: 'carol work' }).id;
const sent = call('carol', 'thread.send', { thread: C, body: 'please plan it' }).id;
launch('a', C);
const d = call('a', 'decision.raise', { action: 'deploy', question: 'Ship?', options, recommendation: 'yes', blocking: false }).id;
assert.ok(call('human', 'inbox').some((x) => x.id === d), 'the owner sees the raise');
const items = [
['decisions', 'decisions', { list: 'recent' }],
['inbox entries', 'inbox', {}],
['claims', 'agents', {}],
['claims', 'thread.claims', {}],
['tasks', 'tasks', {}],
['tasks', 'state', {}],
['project files', 'project.files', { project: 'stack' }],
['project files', 'project.file', { project: 'stack', path: 'docs/plan.md' }],
];
for (const who of ['carol', 'bob'])
for (const [item, verb, args] of items) assert.equal(code(who, verb, args), 'access-refused', `${item}: ${who} ${verb}`);
assert.deepEqual(call('carol', 'thread.messages', { thread: C }).map((m) => m.id), [sent], 'messages: the user reads its own');
});