Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62c3403e17 | ||
|
|
aecb014279 | ||
|
|
4b1b1bf2e5 |
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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(), "");
|
||||
});
|
||||
Reference in new issue
Block a user