Compare commits

..
7 changed files with 340 additions and 223 deletions

No files matched your search

-70
View File
@@ -1,70 +0,0 @@
# 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
@@ -1,128 +0,0 @@
# 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
@@ -1,23 +0,0 @@
# 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.
+138
View File
@@ -0,0 +1,138 @@
# Row 207 build notes: 193c, `mosaic access user` verbs and `--level viewer` (#1705)
Builder Megavolt, reviewer Duckworth, gate owner Sage (sage-2790, Q220, mos-1301). Branch megavolt/row207 off
aca16404c. The brief is "Brief: 193c, mosaic access user verbs and --level viewer" in
docs/plans/2026-10-20_human-users-build.md (blob c74af2510). Its gate: Duckworth approves the manifest, the cli
suite and every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
## Sources
- The brief, quoted: "`mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5 already
has all of them, this row records that and closes with no code."
- Morgana's row 193 design, section 4.5 and section 8 (agents/morgana/work/queue-193/DESIGN.md at e2d161e2e on
morgana/row193): `user add <id> --home local --subject <provider>:<sub>`, `user remove <id>`, `--level viewer`
on `grant`, a receipt line in `<dataRoot>/state/access-log.jsonl` and the `access.reload` call.
- R5 as it stands at aca16404c: packages/cli/src/access.mjs, packages/cli/tests/access.test.mjs,
packages/business/src/access.mjs (checkAccess, read only here), packages/bus/src/broker.mjs and runtime.mjs.
## 1. What R5 already has
- `grant --level viewer`: present. LEVELS is `["collaborator", "viewer"]` (packages/business/src/vocabulary.mjs),
grant checks `--level` against it, and access.test.mjs already grants at viewer. This row adds no code for it
and records it here.
- `user add` and `user remove`: absent. accessVerb takes only list, grant and revoke, and the only way to add a
user today is to hand-edit access.json. This row adds them.
- `access.reload`: absent everywhere. No bus verb has that name. The R5 brief says "R4 later makes these verbs
call `access.reload`, so R5 leaves no other reload path", FED R3's brief leaves it to R4, and access.mjs's
proveHuman says "Row R4 swaps this call for access.reload". FED R4 is not filed.
## 2. Decisions (Q items for Sage and Duckworth, stated as assumptions, not blockers)
- Q1, the reload call. 193c adds no `access.reload` call, because there is no verb to call. The user verbs take
R5's path exactly: the same `project.list` human proof, and R4 swaps the proof for `access.reload` on all five
verbs at once. Nothing is lost meanwhile: runtime.mjs hands the broker `() => loadAccess(...)`, and the broker
reads the file once per user request (broker.mjs:384), so a written change acts on the next request. If Sage
or Duckworth reads the brief as "add `access.reload` here", that is R4's bus verb and a scope change.
- Q2, remove with grants. `user remove` refuses with `user-has-grants`, exit 2, naming the grant ids, while any
grant at any business names the user. The other reading drops the user's grants in the same revision. Refusing
is the smaller, fail-closed choice: nothing disappears that the owner did not name, and the help text already
says to revoke first. checkAccess would also refuse the file (a grant naming a missing user), but with a key
path, not a reason, and exit 3.
- Q3, the subject. `--subject PROVIDER:SUB` splits at the first colon, so a sub may hold colons. One subject per
add, because parse keeps one value per flag. A second subject is a later row if anyone needs it; checkAccess
allows several.
- Q4, `--business`. Required on the user verbs too. The user list is instance-wide, but the human proof runs
through one business's bus, and the receipt names it.
- Q5, the receipt. `{at, verb: "user.add" | "user.remove", business, user, actor: "human", revisionBefore,
revisionAfter, grants: []}`: grant and revoke's shape, with an empty grants list.
- `user remove` of an id not in the file refuses, exit 2, and writes nothing, as revoke of nothing does.
- `user add` of a duplicate id, a reserved id (human, tester), a business's human, an unknown provider, a
non-local home, an owner sso subject or a subject another user holds refuses through checkAccess, exit 3,
before any write. The verb does not repeat those checks.
### Rulings (sage-2810, 16:40Z, cc Duckworth)
- Q1 agreed: no reload call. The reason is broker.mjs:384-386, the `#access` field's comment: "() => the checked
access document (R1's loadAccess), read once per user request", wired in runtime.mjs:263 as
`() => loadAccess(access.file, access.context)`. A write acts on the next request. FED R4 owns any reload verb.
- Q2 agreed: `user remove` refuses with `user-has-grants`, exit 2, naming the grant ids, and writes nothing.
Dropping the grants silently would make removal revoke access without saying so.
- Usage and help pins: grep the tests for the changed strings, sha256 and snapshot pins included, and run every
reader whole (section 6).
- Row 203 (Quackerjack) does not touch access.mjs; its stale help lines stay a follow-up, so no overlap.
### The usage and help pin sweep (sage-2810), on aca16404c plus this row
- Source readers of ACCESS_USAGE and ACCESS_HELP: packages/cli/src/access.mjs only (cli.mjs imports
accessCommand and accessContextFor, not the strings).
- `git grep` over 'packages/*/tests/*' and 'scripts/*' for ACCESS_USAGE, ACCESS_HELP, "mosaic access",
"access list", "access grant", "access revoke", "Access (FED" and the help's "revoke them here first": the only
hits are packages/cli/tests/access.test.mjs (positive control: `accessCommand` hits it twice).
- Hash pins: no test file names packages/cli/README.md or packages/cli/src/access.mjs next to a sha256 or a
64-hex value. The one 64-hex file that mentions a README (packages/queue/tests/fixtures/queue-marked.md) names
packages/queue/README.md and packages/ledger/README.md, not this one. scripts/stack-copy.sh copies
packages/cli/README.md; stack-copy.test.mjs builds its own fake repo and is in the cli package, run whole.
- packages/business/tests/access*.test.mjs import packages/business/src/access.mjs, a different file, unchanged.
- So the readers are access.test.mjs, run whole in step 1, and the cli package whole in step 2.
## 3. The change (code and docs)
- packages/cli/src/access.mjs: two ACCESS_USAGE lines and one help line; accessVerb accepts `user add ID` and
`user remove ID` (three positionals) and hands off to `userVerb` after the shared checks (tester, business,
proof, file, context, revision). `userVerb` takes the lock, checks the revision, builds the new users list,
runs checkAccess on the whole document, writes atomically and appends the receipt.
- packages/cli/README.md: the usage block and one paragraph.
- No change to packages/business, packages/bus or cli.mjs (it already routes `access` to accessCommand).
## 4. Tests (packages/cli/tests/access.test.mjs, whole file)
Manifest check against Morgana's row 275 (sage-2790), 10-22: morgana/row275 has no commits past aca16404c, and
row 275's brief ("193b1b, viewer verbs and board refusals") is bus only: the viewer verb list, `covers` level
checks and board refusals, gated on the bus suite. This row touches only packages/cli (access.mjs, README,
access.test.mjs) and this file, so no path is shared on the briefs. I recheck the paths when Morgana pushes.
- T1 `user add` writes the user (id, home, one subject), bumps the revision by one, prints the line and appends
one receipt with verb `user.add` and `grants: []`. A sub with a colon (`authentik:bob:x`) keeps `bob:x`.
- T2 `user add` refusals before any write, file and receipts unchanged: missing `--home` or `--subject`, a
subject with no colon, an empty provider or sub (exit 4); an unknown provider, a duplicate id, `tester`, the
business's human, an owner sso subject, a subject alice already holds, a non-local home (exit 3).
- T3 `user remove` of a user with no grants drops them, bumps the revision, receipt `user.remove`.
- T4 `user remove` of a user with a grant refuses `user-has-grants` naming the grant id, exit 2, nothing written.
- T5 `user remove` of an id not in the file refuses, exit 2, nothing written.
- T6 a stale `--revision` refuses `revision-conflict` on both user verbs, exit 3.
- T7 `--tester` and an agent env (MOSAIC_RUN_ID) refuse both user verbs, exit 3, and the fake human CLI is never
called.
- T8 a bad shape (`user`, `user add`, `user list x`, `user add a b`) prints the help, exit 4.
- T9 a bus that refuses the human proof (the fixture's `mode: "refuse"`) blocks both user verbs: exit 3, the
file and receipts unchanged.
## 5. Mutants
Each runs the whole access.test.mjs and counts as killed only on an assertion in the named test.
- M1 drop the `user-has-grants` refusal. Second fence: checkAccess then refuses the file with exit 3, so T4 must
assert the `user-has-grants` text and exit 2, not only "nothing written". Killed by T4.
- M2 drop the "no user to remove" check. The write then lands with an unchanged user list. Killed by T5.
- M3 split the subject at the last colon. Killed by T1's colon case.
- M4 skip checkAccess in userVerb (write the unchecked document). Killed by T2's exit-3 cases.
- M5 drop the receipt in userVerb. Killed by T1 and T3.
- M6 drop the revision check in userVerb. Killed by T6.
- M7 exempt the user verbs from the `--tester` check (`&& sub !== "user"`). Killed by T7's tester case.
- M8 skip proveHuman for the user verbs (`if (sub !== "user")`). Killed by T9. T7's agent case does not reach it: accessCommand
calls refuseInsideAgent before accessVerb, so an agent env is refused whatever the order inside.
## 6. Gate plan (mos-1275)
On Sage's CLEAR, from a rebase onto 253's landing under a new branch name, stopping at the first red:
1. access.test.mjs whole, then M1-M8, each under `timeout -k 10 180`.
2. The cli package whole.
3. Every `scripts/test-*.sh` as `timeout -k 10 LIMIT bash scripts/test-X.sh`, no script edited. LIMIT is row
281's table (Gander's row 278 limits, 3x the longest of three records plus 120 s): auth 126, conductor 145,
config 130, discord 147, extension-package 130, foundation 240, land-work 158, queue 191, release 157,
task 231 seconds. The base has the same ten scripts. Any timeout goes to Sage, then Mos, unplaced.
## Runs
Filled in after the runs.
+10
View File
@@ -1084,6 +1084,8 @@ prints a token. A usage error exits 4 and a bus refusal 2.
mosaic access list --business ID [--json]
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N
mosaic access revoke --business ID --user ID [--grant GID] --revision N
mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N
mosaic access user remove ID --business ID --revision N
```
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
@@ -1107,6 +1109,14 @@ once. Removing a provider from `console.sso` takes effect only after a Console r
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
`user add` and `user remove` (row 193c, #1705) take the same proof, lock, revision check, file
checks and receipt. `user add` takes one sign-in subject, `PROVIDER:SUB`, split at the first colon;
the provider must be in `console.sso`. `user remove` refuses with `user-has-grants` while any grant,
at any business, names the user: revoke those first. `--business` names the business whose bus
takes the proof; the user list itself is instance-wide. Their receipt verbs are `user.add` and
`user.remove`. The bus has no `access.reload` yet (FED R4). As with grant and revoke, the broker
reads the file once per user request (`packages/bus/src/broker.mjs`), so the change acts at once.
## Token usage
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
+55 -2
View File
@@ -1,4 +1,5 @@
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
// `mosaic access list|grant|revoke|user` (federation design section 3.5, FED R5, #414;
// `user add|remove`, row 193c, #1705).
// The human edits <configDir>/access.json here. Each write is checked against the
// instance's context before it lands, bumps the revision by one, and appends one
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
@@ -18,6 +19,8 @@ export const ACCESS_USAGE = [
"mosaic access list --business ID [--json]",
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N",
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
"mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N",
"mosaic access user remove ID --business ID --revision N",
].join("\n");
// The help text. A grant's `instances` is the real limit: the granted instance
@@ -33,6 +36,7 @@ list is the real limit: a collaborator's message drives a granted instance with
the owner's role.
To cut a user off, revoke them here first. It acts at once. Removing a provider
from console.sso takes effect only after a Console restart, so do that second.
user remove refuses while the user has a grant at any business: revoke those first.
Peer homes are not read yet (row 203 adds the federation key), so a grant that
names a peer home refuses. A grant that names a sign-in subject needs console.sso
in the system config; without it every such grant refuses.
@@ -206,7 +210,8 @@ export async function accessCommand(args, io, deps) {
async function accessVerb(args, io, deps) {
const parsed = parse(args);
const sub = parsed.positional[0];
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
const shape = sub === "user" ? ["add", "remove"].includes(parsed.positional[1]) && parsed.positional.length === 3 : ["list", "grant", "revoke"].includes(sub) && parsed.positional.length === 1;
if (!shape) throw new CliError(ACCESS_HELP, 4);
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
const business = parsed.values["--business"];
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
@@ -224,6 +229,7 @@ async function accessVerb(args, io, deps) {
const expected = parsed.values["--revision"];
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
if (sub === "user") return userVerb(parsed, io, deps, { business, file, context, expected });
const user = parsed.values["--user"];
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
@@ -278,6 +284,53 @@ async function accessVerb(args, io, deps) {
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
}
// `user add ID` and `user remove ID` (row 193c): the same lock, revision check, file checks,
// atomic write and receipt as grant and revoke. add takes one subject, PROVIDER:SUB, split at the
// first colon, so a sub may hold colons. remove refuses while any grant names the user, at any
// business, so a user is never removed with live grants (the owner revokes first, as the help says).
async function userVerb(parsed, io, deps, { business, file, context, expected }) {
const [, action, user] = parsed.positional;
let entry;
if (action === "add") {
const home = parsed.values["--home"];
const subject = parsed.values["--subject"] ?? "";
const colon = subject.indexOf(":");
if (home === undefined || colon < 1 || colon === subject.length - 1) {
throw new CliError(`user add needs --home and --subject PROVIDER:SUB\n${ACCESS_USAGE}`, 4);
}
entry = { id: user, home, subjects: [{ provider: subject.slice(0, colon), sub: subject.slice(colon + 1) }] };
}
const result = withLock(file, () => {
const { document, revision } = readCurrent(file, context);
if (Number(expected) !== revision) {
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
}
let users;
if (action === "add") users = [...document.users, entry];
else {
if (!document.users.some((u) => u.id === user)) throw new CliError(`refused: no user ${user} to remove`, 2);
const held = document.grants.filter((g) => g.user === user).map((g) => g.id);
if (held.length) throw new CliError(`refused: user-has-grants: ${user} still holds ${held.join(", ")}; revoke them first`, 2);
users = document.users.filter((u) => u.id !== user);
}
const checked = checkAccess({ ...document, revision: revision + 1, users }, context);
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
return { checked, revision };
}, deps.fs);
receipt(deps.system.dataRoot, {
at: (deps.now ?? (() => new Date()))().toISOString(),
verb: `user.${action}`,
business,
user,
actor: "human",
revisionBefore: result.revision,
revisionAfter: result.checked.revision,
grants: [],
});
io.stdout.write(`access user ${action}: ${user}, revision ${result.checked.revision}\n`);
}
function formatList(doc) {
const lines = [`access file revision ${doc.revision ?? "none"}`];
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
+137
View File
@@ -312,3 +312,140 @@ test("the real human-cli, run from agent ancestry, refuses and writes nothing",
assert.equal(readFileSync(file, "utf8"), before);
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
});
// Row 193c (#1705): `user add` and `user remove`. BUILD.md (agents/megavolt/work/queue-207) names
// each test T1-T9 and the mutant it kills.
const USER_ADD = (id, { subject = "authentik:bob-sub", home = "local", revision = "1" } = {}) => [
"user", "add", id, "--business", "acme", "--home", home, "--subject", subject, "--revision", revision,
];
const USER_REMOVE = (id, revision = "1") => ["user", "remove", id, "--business", "acme", "--revision", revision];
test("T1 user add writes the user one revision up, prints it and appends a user.add receipt", async (t) => {
const s = setup(t);
const out = await run(USER_ADD("bob", { subject: "authentik:bob:x" }), s.deps);
assert.match(out, /access user add: bob, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.deepEqual(doc.users[1], { id: "bob", home: "local", subjects: [{ provider: "authentik", sub: "bob:x" }] });
assert.deepEqual(doc.users[0], DOC.users[0]);
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(lines.length, 1);
assert.deepEqual(lines[0], {
at: "2026-10-20T20:00:00.000Z", verb: "user.add", business: "acme", user: "bob", actor: "human",
revisionBefore: 1, revisionAfter: 2, grants: [],
});
});
test("T2 a bad user add refuses before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const usage = [
["user", "add", "bob", "--business", "acme", "--subject", "authentik:bob-sub", "--revision", "1"],
["user", "add", "bob", "--business", "acme", "--home", "local", "--revision", "1"],
USER_ADD("bob", { subject: "authentik" }),
USER_ADD("bob", { subject: ":bob-sub" }),
USER_ADD("bob", { subject: "authentik:" }),
];
for (const args of usage) {
const err = await refusal(run(args, s.deps), /user add needs --home and --subject PROVIDER:SUB/);
assert.equal(err.exitCode, 4, args.join(" "));
}
const checked = [
[USER_ADD("bob", { subject: "github:bob-sub" }), /provider must be a configured provider/],
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id: alice/],
[USER_ADD("tester"), /a bus principal/],
[USER_ADD("jason"), /a business's human/],
[USER_ADD("bob", { subject: "authentik:jason-sub" }), /owner sign-in subject/],
[USER_ADD("bob", { subject: "authentik:alice-sub" }), /appears more than once/],
[USER_ADD("bob", { home: "peer-one" }), /home must be a configured peer/],
];
for (const [args, pattern] of checked) {
const err = await refusal(run(args, s.deps), pattern);
assert.equal(err.exitCode, 3, args.join(" "));
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T3 user remove of a user with no grant drops them one revision up, with a user.remove receipt", async (t) => {
const s = setup(t);
const out = await run(USER_REMOVE("alice"), s.deps);
assert.match(out, /access user remove: alice, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.deepEqual(doc.users, []);
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(line.verb, "user.remove");
assert.equal(line.user, "alice");
assert.equal(line.revisionBefore, 1);
assert.equal(line.revisionAfter, 2);
assert.deepEqual(line.grants, []);
});
// M1's second fence: without the user-has-grants refusal, checkAccess refuses the file with exit 3
// (a grant naming a missing user). The test pins the reason and exit 2, so only the verb's refusal passes.
test("T4 user remove refuses with user-has-grants while the user holds a grant, and writes nothing", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
const id = s.read().grants[0].id;
const before = readFileSync(s.file, "utf8");
const err = await refusal(run(USER_REMOVE("alice", "2"), s.deps), /user-has-grants: alice still holds /);
assert.equal(err.exitCode, 2);
assert.ok(err.message.includes(id));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts().trim().split("\n").length, 1);
});
test("T5 user remove of an id not in the file refuses and writes nothing", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run(USER_REMOVE("carol"), s.deps), /no user carol to remove/);
assert.equal(err.exitCode, 2);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T6 a stale revision refuses both user verbs with revision-conflict", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob", { revision: "2" }), USER_REMOVE("alice", "5")]) {
const err = await refusal(run(args, s.deps), /revision-conflict/);
assert.equal(err.exitCode, 3);
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T7 --tester and an agent caller are refused on both user verbs before the bus is asked", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob"), USER_REMOVE("alice")]) {
const tester = await refusal(run([...args, "--tester"], s.deps), /not --tester/);
assert.equal(tester.exitCode, 3);
const agent = await refusal(run(args, s.deps, { ...humanEnv(), MOSAIC_RUN_ID: "run-1" }), /human shell/);
assert.equal(agent.exitCode, 3);
}
assert.equal(existsSync(s.log), false);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T8 a user verb of the wrong shape prints the help", async (t) => {
const s = setup(t);
for (const shape of [["user"], ["user", "add"], ["user", "list", "x"], ["user", "add", "a", "b"]]) {
const err = await refusal(run([...shape, "--business", "acme", "--revision", "1"], s.deps), /mosaic access user add ID/);
assert.equal(err.exitCode, 4, shape.join(" "));
}
assert.equal(existsSync(s.log), false);
});
test("T9 a bus that refuses the human proof blocks both user verbs", async (t) => {
const s = setup(t, { mode: "refuse" });
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob"), USER_REMOVE("alice")]) {
const err = await refusal(run(args, s.deps), /human-required/);
assert.equal(err.exitCode, 3);
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});