Compare commits

..
Author SHA1 Message Date
jason.woltje 5d587a552d row284: cover absent supporting prefix directly (unrun) 2026-10-23 14:44:59 -05:00
jason.woltje 47db04eba8 row284: supporting lookup scope and served blob IDs (unrun) 2026-10-23 14:39:33 -05:00
jason.woltje 0ed4fbab9b row284: migrate playground get tests under mos-1330 (unrun) 2026-10-23 14:33:28 -05:00
jason.woltje fc05bcbc39 row284: merge row224 intact onto row283 prerequisite (unrun) 2026-10-23 14:32:34 -05:00
jason.woltje 46bad7ea3b row 283 (#1788): sage-2925/sage-3649: optional editor.user (her Gitea login for library-human, read by row 288), checked with USER as the entry user is, kept in the output only when given; T3 accepted and refused cases; README; mutants M6e/M6f; BUILD.md records the departure from DESIGN.md section 1. Untested 2026-10-23 14:28:44 -05:00
jason.woltje e67c27b322 row 283 (#1788): webby-0236 E5: the git round-trip test drops GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR and GIT_INDEX_FILE and reads no global or system config (repo convention); BUILD.md records E5. Untested 2026-10-23 14:15:06 -05:00
jason.woltje 76d651dba4 row 283 (#1788): E4 per sage-3606: editor values starting or ending with git's crud() set (bytes <= 0x20 and , : ; < > " \ ') refuse library-mapping-invalid; T3 cases per character and key, inner chars pinned, git round-trip test; mutants M6c/M6d; BUILD.md cites crud() and records the claim (rev 3642). Untested 2026-10-23 14:12:24 -05:00
jason.woltje 88ec5e8e47 row 283 (#1788): E2 per sage-3601: editor values with surrounding whitespace refuse library-mapping-invalid (git trims idents at commit); T3 padded cases per key, mutant M6c; BUILD.md records the departure from DESIGN.md section 1 and the fixture grep (none). Untested 2026-10-23 14:02:37 -05:00
jason.woltje b9ddb6c5e2 row 283 (#1788): webby-0227 E3: supportingPath checks the type before stringifying, so a non-string path refuses library-write-story-path; string case covers 7, null, undefined, 1n and an array; BUILD.md records E1-E3, E2 to Sage. Untested 2026-10-23 14:01:33 -05:00
jason.woltje bd2c1c4ea1 row 283 (#1788): BUILD.md: drift check, N3 grep, mos-1330 :73 old and new text, Q2 tradeoff, Q4 handoff to row 288 via #1793 c30738, mutants M1-M8 exact strings, gate plan; doc only 2026-10-23 13:59:07 -05:00
jason.woltje 28918824ca row 283 (#1788): survey.test:73 re-pin under mos-1330 item 4. The scopes line gains only supporting 0; old 'scopes canon 5, playground 1, unmapped 3; indexed 6', new 'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6' 2026-10-23 13:58:17 -05:00
jason.woltje 331653b385 row 283 (#1788): C2-01d supporting folders. supporting lists on every universe kind with the playground's prefix rules, library-supporting-other-universe and the cross-list rules; optional editor {name, email} refusing controls, < and >; classify checks supporting before playground; supportingPath with the eight checks over the listTree array, exported; survey counts supporting (sage-2923); README; tests T1-T8. Untested: runs wait for Sage's CLEAR 2026-10-23 13:58:13 -05:00
jason.woltje 4f66dccdec row 283 (#1788): design draft 2 folds webby-0169 R1 (editor refuses < and >, T3, M6b) and N1 (check 8 compares NFC and case, NFD case in T6, M4b); Q2-Q4 accepted, Q4 handoff to C2-02 in BUILD.md; doc only 2026-10-23 13:52:54 -05:00
jason.woltje 398314cf4d row 283 (#1788): design draft 1. supporting lists with the playground's prefix rules and library-supporting-other-universe, cross-list rules, the optional editor, supporting first in classify, supportingPath over the listTree array; survey.mjs departure Q1 for Sage, Q2-Q5 for Webby; doc only (sage-2906) 2026-10-23 13:52:54 -05:00
jason.woltje 23816cf285 row224: pace failed inline sync attempts (unrun) 2026-10-22 11:50:19 -05:00
jason.woltje 38e322ea01 row224: draft broker library lookups and typed tools (unrun) 2026-10-22 11:45:58 -05:00
jason.woltje 4f3fbc25a0 row 216 (C2-05a, #1717): library index cache and lookup queries find, get, search, manuscript, universeInfo; get pages links, manuscript takes no chapter, search marks a too-long line, PRD 1.14 levels (rulings da9555ca2); unrun 2026-10-22 09:42:51 -05:00
58 changed files with 2808 additions and 1762 deletions

No files matched your search

-6
View File
@@ -5411,9 +5411,3 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval.
2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta.
2026-10-23 | Beakley (88b97f65) | Row 217 #1718 takeover, before implementation | Clean base 45dca69f6 on beakley/row217. Recovering Launchpad handoff for citation guard, live settings, page store and runner retry. Acceptance is C2-06/design 5.2 plus sage-3595/3599/3602. Tracking agents/beakley/work/queue-217/BUILD.md; no product changes or Node/tests; Sage CLEAR required.
2026-10-23 | Beakley (88b97f65) | Row 217 #1718 untested draft checkpoint | Continued Launchpad e25ef0373; added runner retry/text/note implementation, pure protocol constants and sage-3640 approved single read-only mount, CLI documentation and new acceptance test files. Static checks caught missing projects compatibility, async request mutation and Git pathspec matching; bounded fixes and regression tests authored. git diff --check clean. No Node/test/mutant executed; Sage holds CLEAR. 283/216/285 dependency checks and all acceptance execution remain open. Packet agents/beakley/work/queue-217/MANIFEST.md.
2026-10-23 | Beakley (88b97f65) | Row 217 early-look follow-up, Untested | Webby 0939c0682 E1 adds ordinary mallory human-swap test; E2 adds stored trailer-derived agent/file event evidence; E6 creative-sender matrix and E7 runtime readRaw tests authored. Sage-3668/3670 accepts direct writer substitution because setter keys exclude replyLimits (organization.set exists, correcting review premise), and requires distinct availability refusal for E4. Real held-lock/socket and runner-reason regressions plus M5 authored before that production fix; expected RED awaits Sage CLEAR. No Node/tests/mutants, no old-test edits, no green claim.
-157
View File
@@ -1,157 +0,0 @@
# Row 217 — C2-06 citation guard, page store and runner retry
Owner: Beakley (88b97f65-97db-4f05-8c6e-081f92bc3501), assigned by Mos Q245.
Issue: #1718. Reviewer: Webby. Branch: `beakley/row217`.
Brief: `docs/plans/2026-10-20_case2-rows.md`, C2-06.
Status: Untested draft on Launchpad handoff `e25ef0373`; targeted execution awaits Sage CLEAR.
## Plan and boundaries
Continue Launchpad's implementation from its handed-off ref. Complete the guard,
live reply limits and M11 writer fence, broker page store and check evidence,
CLI setter and runner retry/note behavior against the brief and design 5.2.
Supply acceptance tests and mutants, then request Sage's test slot and Webby's
review. Sage owns queue movement, gate and landing. Push only `beakley/*`, add
commits without amend/force or attribution lines. No credential reads.
## Standing rulings received from Sage (sage-3610)
- sage-3595: stub supporting scope until row 283 lands, then rebase onto it and
add real-fixture tests. Landing follows 283. `runtime.mjs` wiring approved.
- Check evidence belongs in an `action.allowed` event in the same transaction
as message insertion; no new event kind. Read the library at the cited commit.
- In library businesses only, JSON over 32,768 characters gets the
`request-too-large` retry. `role.claim` adds `library` only for a library business.
- Store `check.citations` entries with `scope`, `agent`, and `file` (the file's
last commit at the cited commit). On `thread.messages`, add `last` at the served
tip, one git call per distinct path per read. Scope/agent are broker evidence.
- sage-3599: optional `BusError.reason`, guard refusals only, capped at 300
characters. Add bus server/client to manifest. Reasonless envelopes stay
byte-identical.
- `render.mjs` obsolete notice hits belong to row 225.
- Node and tests require Sage CLEAR (sage-3610 supersedes the initial permission
for targeted new tests). Full suites, webui builds and gate require shared slot.
- Existing test edits require Mos GO through Sage; list every file and numstat
below before seeking that approval.
## Recovery and coordination
Initial base `45dca69f6`, clean. `scripts/mosaic queue next beakley` refused:
linked worktrees cannot run the queue. Reported to Sage/Mos in beakley-0001;
Sage will change ownership at a queue gap. No queue files changed.
Read AGENTS.md, agents/README.md, C2-06 brief, design 5.2 and the row 225 BUILD
contract. Requested Launchpad handoff in beakley-0002. Its header timestamp was
mistakenly typed ahead of the actual clock; sequence key identifies the message.
## Existing test changes
None yet. No tests or Node commands run.
## Handoff adopted and independent work (2026-10-23)
Fast-forwarded `beakley/row217` to Launchpad `e25ef0373` on `45dca69f6`.
The full original handoff/rulings remain at
`agents/launchpad/work/queue-217/BUILD.md`. No handoff code was rebuilt from scratch.
Implemented the runner path: role.claim library switch; exact fixed texts; note
inside JSON text with citations retained and no JSON clip; fixed texts defer
notes; one same-session retry for guard codes and both existing size caps;
retry fallback chosen from the second refusal; separate usage turn id for retry.
Outside library businesses the existing send path remains intact.
Added the CLI README usage paragraph. New acceptance tests cover library parsing,
real Git reachability/quotes/scope/origin, settings fallback, M11 direct writer
and serialization, CLI dispatch, broker insert/check atomicity, live settings,
page bounds/lifecycle, socket reasons, per-path last reads, request snapshots,
and runner RESULT/typed send_message, fixed texts, long JSON and unslop behavior.
These are authored evidence only: none has executed.
Static defects corrected locally: a missing projects object now means no library;
the guard sends its captured request arguments after the asynchronous check;
origin/last Git logs use literal pathspecs; a web quote can match any retained
fetch of the cited URL. Each has new-file regression coverage.
## Scope additions and cross-row contract
- `packages/library/src/reply-protocol.mjs`: shared GUARD_CODES/FIXED_TEXTS,
import-free, so runner imports do not reach host Git modules. guard.mjs
reexports them to preserve Launchpad's API. Fethry confirmed no renderer import
is needed when the renderer simply displays fixed texts as ordinary text.
- `packages/seat/src/sandbox.mjs`: one RUNNER_FILES entry for that pure file,
approved by sage-3640. No other mount change. The existing sandbox.test.mjs
import-closure test must pass unchanged; seat joins the gate list.
- Runtime hunks remain exactly Launchpad's import and library guard/limits/last
adapters, plus library.limits.set dispatch. Drift checks wait on 216 and 285.
- Status contract confirmed with Fethry (beakley-0004, fethry-0009): nested
`replyLimits: {requireCitation, citationsPerReply, fallback: [keys],
problem: "reply-limits-unreadable" | null}`. Check entries remain ordered
`scope/agent/file`, plus `last` on reads; no changed boolean.
## Verification and remaining acceptance
- Static `git diff --check`: clean so far. No Node, suite, test or mutant run.
- Existing test file edits: none (numstat 0/0 for every pre-existing test).
All new test files are listed in manifest.r1.sha256; no Mos approval requested.
- `mutants.py` is authored for isolated HEAD archives with green controls:
M1 remove both writer fences; M2 compare incoming human; M3 split evidence
from insert; M4 drop client reason. Not executed.
- Still required: Sage CLEAR for targeted runs; fix first-run failures; supporting
scope acceptance against row 283; 216 index/readLibrary fixture comparison;
285/runtime drift check; full library/business/bus/cli/harness/seat suites and
every scripts/test-*.sh on Sage's gate; mutant execution; Webby approval.
- Before in-review, perform brief's obsolete-code/key search and report outside
ownership hits to Sage. render.mjs/render.test.mjs hits belong to 225.
- No queue changes, protected branch pushes, gate claims or acceptance claims.
Static obsolete-code/key search completed: only packages/library/src/render.mjs:6 and packages/library/tests/render.test.mjs:58 match no-cited-answer; both belong to row 225 per standing ruling. No owned-path hit. Repeat before in-review after dependency changes.
## Webby early look 0939c0682 / webby-0249 and sage-3668/3670
This remains an untested draft. The follow-up adds tests only; production E4
changes wait for the held-lock regression's first RED under Sage CLEAR.
- E1: added an ordinary actor `mallory` that sets both `human` to itself and
`replyLimits`. The existing role/tester actors did not distinguish M2 because
the actor-shape exclusions rejected them independently of the human comparison.
M2 is still unrun; no kill claimed.
- E2: added a real bus event assertion for `agent: "acme/scribe"` from a file's
Mosaic-Agent trailer. The cited tip includes a later unrelated commit, so the
event must store the file commit rather than the library tip. thread.messages
must show `last === file` for that unchanged path. Supporting scope still waits
for row 283's real fixture, as ruled.
- E3: sage-3668 accepts direct writer.write tests under chief and tester actors
as substitutes for the brief's setter cases (brief lines 483-491 at base
45dca69f6). Correction to the early-look premise: organization.set DOES exist,
broker.mjs:4701/4709, but ORGANIZATION_KEYS excludes replyLimits; instances.set
also cannot carry it. Direct writes exercise the shared M11 boundary those
setters cannot reach with that input. Sage confirmed the accurate reason in
sage-3670; the unrelated actor case remains required.
- E4: traced readSyncState -> withLibraryLock -> library-sync-busy after five
seconds -> servedMirror catch-all -> null served -> misleading citation-invalid.
New real-flock/socket test first proves the cite valid, holds the lock, asserts
the exact citation-unavailable reason the agent receives, verifies no insert,
and checks fixed fallback delivery and recovery after unlock. Additional tests
distinguish malformed sync state from absent/new mirrors, and assert that the
actual runner retry request contains that reason. These tests are expected RED
until the fix. Planned production change: only an absent mirror or legitimate
empty state yields null; other failures become GuardError citation-unavailable;
bodies without path cites do not require the mirror lock. Add the code to
GUARD_CODES so the runner sends the agent that reason on its one retry. Per
sage-3670, existing four fallback bodies can remain only if that delivery is
verified. M5 restores catch-all behavior; authored, not run.
- E5: first-library-project selection is a Launchpad handoff implementation
choice, not a validated rule that a business can only have one library.
Cites into another library remain a limitation. No mapping/schema rule added.
- E6: the broker's full guard table now runs as both coder and research.
- E7: new citation-runtime.test.mjs starts the real runtime, reads limits from
the real organization file, then changes, corrupts and removes that fixture
file and checks library.status without replacing the library adapter.
No Node, tests or mutants ran. No test that predated row 217 changed.
## Webby follow-up 192049c98 / webby-0258
E1, E2, E6 and E7 closed statically; no executed acceptance claimed. E3 correction and E5 limitation accepted. E4 tests accepted statically; real RED then production fix/green remain on hold for Sage CLEAR, with supporting scope waiting on 283. F1: citation-helpers status now uses its mutable served value, matching guard/last after setServed instead of reporting the original fixture commit. Test-only fixture correction, unrun. manifest.r3.sha256 records the updated helper; no production or old-test changes.
-68
View File
@@ -1,68 +0,0 @@
# Row 217 draft manifest — Untested
Issue #1718, Case 2 C2-06. Base `45dca69f6`, continued from Launchpad
`e25ef0373`. This is a checkpoint for static review, not an in-review or
acceptance claim. `manifest.r1.sha256` freezes the product and test files.
## Product scope
| Path | Change |
| --- | --- |
| packages/library/src/guard.mjs | Schema and seven checks, real cited-commit reads, origins and latest path commits |
| packages/library/src/limits.mjs | Pure defaults, validation and conservative unreadable-file fallback |
| packages/library/src/reply-protocol.mjs | Import-free guard codes and four runner fixed texts |
| packages/library/src/index.mjs | Guard and limits exports |
| packages/business/src/business.mjs | Permit any replyLimits value without widening another schema |
| packages/business/src/organization.mjs | M11 fence on input and serialized output, human setter, raw live reader |
| packages/bus/src/broker.mjs | Async pre-insert guard, atomic existing-kind check event, per-session pages, settings verb/status, optional library claim flag and read-time last evidence |
| packages/bus/src/runtime.mjs | Inject guard/live limits/last and dispatch human setter (sage-3595) |
| packages/bus/src/server.mjs | Await guarded sends and thread reads; optional guard reason (sage-3599) |
| packages/bus/src/client.mjs | Preserve optional capped reason (sage-3599) |
| packages/harness/src/runner.mjs | Library texts, one retry, JSON note/clip behavior and retry usage tracking |
| packages/seat/src/sandbox.mjs | Exactly one read-only RUNNER_FILES addition for the pure protocol (sage-3640) |
| packages/cli/src/limits.mjs | Human limits replies file command |
| packages/cli/README.md | Reply settings usage |
No dependency, SQL schema, event kind, role authority or credential changes.
The additional sandbox file carries only constants and has no imports.
## New tests and fixtures
| Path | Acceptance focus |
| --- | --- |
| packages/library/tests/guard.test.mjs | Seven table checks, cap boundaries, real Git origins/reachability/literal names, web quotes and limits fallback |
| packages/business/tests/reply-limits.test.mjs | Human-only M11, old-human comparison, dotted agent identity, serialization, invalid values and raw reads |
| packages/bus/tests/citation-helpers.mjs | Shared made-up repository and real broker fixture |
| packages/bus/tests/citation-guard.test.mjs | Refusal insertion counts, transaction failure, live settings/status, page bounds, socket envelopes, file/last and async snapshot |
| packages/cli/tests/reply-limits.test.mjs | Revision read, exact setter args and usage refusals |
| packages/harness/tests/fixtures/citation-adapter.mjs | Scripted local harness, typed calls and unslop events |
| packages/harness/tests/citation-runner.test.mjs | Every RESULT refusal and typed send_message, one retry, both size caps, plain multibyte answers, fixed texts and notes, old non-library behavior |
No existing test changed. Existing role.claim pins and the seat import-closure
test stay untouched. Existing test numstat: none.
## Review focus and outstanding evidence
The check event and message insert must roll back together. Scope/agent/file
come from Git; `last` is read once per distinct path per read. Guard refusals
alone have a reason, bounded at 300 characters without quote or URL content.
Captured arguments must be the ones inserted after async validation. Every
plain or JSON length refusal shares the same single retry budget.
All tests and four isolated mutants are unrun pending Sage CLEAR. The supporting
scope tests require row 283; the index-set comparison requires row 216; runtime
drift must be checked against 216/285 before in-review. Landing follows 283.
Full gate: library, business, bus, cli, harness and seat, plus every
scripts/test-*.sh. Webby reviews the final tested manifest; Sage owns the gate,
queue and landing. No push to a protected branch is authorized.
## Early-look follow-up packet (manifest.r2.sha256)
Tests-only follow-up to Webby 0939c0682. Adds ordinary-actor M2 discrimination,
stored agent/file evidence at an unrelated tip, creative-sender table coverage,
and real runtime readRaw coverage. New citation-availability.test.mjs and runner
availability case are intentionally unrun regressions for E4/sage-3668/3670.
Production availability handling is not implemented yet: run that targeted RED
on CLEAR before applying its fix, then rerun the same cases. M5 restores the
catch-all. This packet is not ready for a green gate or manifest approval.
@@ -1,21 +0,0 @@
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
29114051a9bc5271e48978a9552d64b51b0ac4810f94a1f98940bf617b148df8 packages/bus/tests/citation-guard.test.mjs
dc2d5836f10f591fc174aaf9587a3bff793b24ac29efe9ab68c6a9243d1d1c2c packages/bus/tests/citation-helpers.mjs
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
bec04a894bc2af000548283ed7719e4de1c6d56688f42238a113390e9b2d3fbe packages/business/tests/reply-limits.test.mjs
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
ec7ef0cbf493217eb9229895cae2fe7ef603f41913840e59a73220df387c81ca packages/harness/tests/citation-runner.test.mjs
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
@@ -1,23 +0,0 @@
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
cdd0304ec25ef1ad3e31c52502fa46f2c80bd77c8d3c2e13f8ed4b5328f342c9 packages/bus/tests/citation-availability.test.mjs
8dc344b8a905c58d544b031ca84adb7eb869d9de88eb8e6b0fc1d9190702a38a packages/bus/tests/citation-guard.test.mjs
62d8a55945d4b700e22c8c49b6420a5e25719d266901a2ad20c72514735e65e9 packages/bus/tests/citation-helpers.mjs
168dcac99d9bfdfdf5ff0eaa2c0fff91595b351b4d0092dc299532c9c32869f0 packages/bus/tests/citation-runtime.test.mjs
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
4d2eb11beaa80175e524af1b95c81e7164990fba1d3e1754f5ba06eee390a14f packages/business/tests/reply-limits.test.mjs
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
a5ef1b65230239a7baf6d0e8f2b8a51c0516348c7c4339a55b3f0a1a6ff531ba packages/harness/tests/citation-runner.test.mjs
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
@@ -1,23 +0,0 @@
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
cdd0304ec25ef1ad3e31c52502fa46f2c80bd77c8d3c2e13f8ed4b5328f342c9 packages/bus/tests/citation-availability.test.mjs
8dc344b8a905c58d544b031ca84adb7eb869d9de88eb8e6b0fc1d9190702a38a packages/bus/tests/citation-guard.test.mjs
8a2d4b636f8d4f926c484254c33ecf7b7e4722acef3250d2730593984d5ac06b packages/bus/tests/citation-helpers.mjs
168dcac99d9bfdfdf5ff0eaa2c0fff91595b351b4d0092dc299532c9c32869f0 packages/bus/tests/citation-runtime.test.mjs
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
4d2eb11beaa80175e524af1b95c81e7164990fba1d3e1754f5ba06eee390a14f packages/business/tests/reply-limits.test.mjs
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
a5ef1b65230239a7baf6d0e8f2b8a51c0516348c7c4339a55b3f0a1a6ff531ba packages/harness/tests/citation-runner.test.mjs
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env python3
"""Run only on Sage CLEAR. Mutate isolated HEAD archives; never edit the checkout.
A green control run is required before each negative result counts as a kill.
Usage: python3 agents/beakley/work/queue-217/mutants.py OUTPUT_DIRECTORY
"""
from pathlib import Path
import io
import os
import subprocess
import sys
import tarfile
import tempfile
ROOT = Path(__file__).resolve().parents[4]
OUT = Path(sys.argv[1]).resolve()
OUT.mkdir(parents=True, exist_ok=True)
WRITER = "packages/business/src/organization.mjs"
BROKER = "packages/bus/src/broker.mjs"
CLIENT = "packages/bus/src/client.mjs"
ATOMIC = "if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));"
REENTER = "return this.#reenter(cap, s, { verb: 'message.send', args }, check);"
SPLIT = """const result = this.#reenter(cap, s, { verb: 'message.send', args }, check);
if (check) this.#store.transaction(() => this.#event(s, 'action.allowed', { action: 'message.checked', message: result.id, check }, result.id, checkEventId(result.id)));
return result;"""
MUTANTS = [
("M1-writer-fence", [(WRITER, " replyLimitsFence(old, document, actor);", ""),
(WRITER, " replyLimitsFence(old, written, actor);", "")],
["packages/business/tests/reply-limits.test.mjs"]),
("M2-incoming-human", [(WRITER, "actor === old.human", "actor === document.human")],
["packages/business/tests/reply-limits.test.mjs"]),
("M3-split-check-transaction", [(BROKER, ATOMIC, ""), (BROKER, REENTER, SPLIT)],
["packages/bus/tests/citation-guard.test.mjs"]),
("M5-hide-library-unavailable", [("packages/library/src/guard.mjs",
"refuse('citation-unavailable', 'The library is busy or unavailable. Retry the answer when the library is available.');",
"served = null;")], ["packages/bus/tests/citation-availability.test.mjs"]),
("M4-drop-client-reason", [(CLIENT, "new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined)", "new BusError(r.error)")],
["packages/bus/tests/citation-guard.test.mjs"]),
]
archive = subprocess.check_output(["git", "archive", "HEAD", "packages", "roles", "scripts", "tools", "docs", "package.json", "RELEASE"], cwd=ROOT)
summary = []
for name, changes, tests in MUTANTS:
with tempfile.TemporaryDirectory(prefix="row217-mutant-") as directory:
work = Path(directory)
with tarfile.open(fileobj=io.BytesIO(archive)) as source:
source.extractall(work, filter="data")
modules = ROOT / "node_modules"
if modules.exists():
os.symlink(modules.resolve(), work / "node_modules", target_is_directory=True)
cmd = ["node", "--test", *tests]
with (OUT / f"{name}-control.log").open("w") as log:
control = subprocess.run(cmd, cwd=work, stdout=log, stderr=subprocess.STDOUT, timeout=120)
if control.returncode:
raise SystemExit(f"{name}: control RED, no mutant result claimed")
for file, before, after in changes:
path = work / file
text = path.read_text()
if text.count(before) != 1:
raise SystemExit(f"{name}: expected exactly one mutation anchor in {file}")
path.write_text(text.replace(before, after))
with (OUT / f"{name}.log").open("w") as log:
mutant = subprocess.run(cmd, cwd=work, stdout=log, stderr=subprocess.STDOUT, timeout=120)
if mutant.returncode == 0:
raise SystemExit(f"{name}: SURVIVED")
summary.append(f"{name}: killed (control green, mutant exit {mutant.returncode})")
(OUT / "summary.txt").write_text("\n".join(summary) + "\n")
print(summary[-1], flush=True)
+170
View File
@@ -0,0 +1,170 @@
# Row 224 — C2-05b broker lookups and typed tools (#1725)
Owner: Della; reviewer: Dewey. Assignment sage-2795; scope extension sage-2804.
Base: `4f3fbc25a` (Researcher C2-05a). Branch: `della/row224`.
Brief: C2-05b in `docs/plans/2026-10-20_case2-rows.md` at `aca16404c`
and `51cc0445c`; design section 3, 5.3 M3 and 7.2; C2-05a BUILD rulings.
## Execution boundary and progress
Code/docs and local Git only while row 253 holds the gate. No Node (including
syntax checks), HTTPS Git, queue claim, test or push until Sage's CLEAR.
Tests are written first; their RED/GREEN runs are deferred, not claimed.
Queue next is deferred too: its executable uses Node. Sage assigned this row
explicitly. After 253 and 216 land, rebase under a new branch name.
- Pre-flight: query.mjs consumes `{repo,commit,mapping}`; broker cannot import
Git/filesystem library code because it is in the sandbox import closure.
Runtime injects the host lookup adapter; existing human file/status/sync stay.
- Pre-flight: resolved has no project list and a library is never a checkout.
Bundle needs an explicit library flag from launcher business configuration.
- M3 reading: exact creative base is five lookups plus send_message; retain
explicitly granted broker web_search/web_fetch for research/companion under
landed C2-08 and sage-2804. No generic spec, task, evidence or decision tools. Residual (mos-1308): those
reads lack broker action checks; their typed-tool absence is the only fence
for creative roles. M1/M2 deny bash; broker-side refusal is outside this row.
Mos confirmed this interpretation in mos-1308; design wording fix belongs to Gosalyn. Other definitions retain their current tools.
- Multi-library businesses use an optional project ID; omit it only with one
configured library. Universe is required for all five tools, including get.
- Missing library uses the brief's `library-not-configured`; argument errors
reuse `invalid-request` and C2-05a query codes. No new action or authority.
## Manifest (draft code, unrun)
- bus/src/broker.mjs: five lookup verbs, strict args, holder/business rechecks.
- bus/src/runtime.mjs: host lookup adapter, stale-before-read sync, served pin.
- harness/src/tools.mjs: five schemas; creative exact-set filtering.
- harness/src/bundle.mjs: library flag forwarded to typedTools.
- cli/src/launcher.mjs: one flag derived from trusted business projects.
- bus/tests/library-lookups.test.mjs: real query/broker/socket/runtime cases.
- harness/tests/library-tools.test.mjs: schemas, role snapshots, bundle parity.
- cli/tests/thread-launch.test.mjs: flag reaches a real launched bundle with no library checkout.
- agents/della/work/queue-224/BUILD.md: manifest, rulings and deferred verification ledger.
## Verification ledger
No test or syntax-check run yet. Acceptance requires full bus/cli/harness suites
and every scripts/test-*.sh on Sage's gate; focused files and affected CLI/
seat import-closure tests precede that only in an authorized CLEAR.
## Coordination
Huey272 reports no runtime/bundle/launcher overlap. Rocko219 changes launcher
THREAD_DEFINITIONS checks only, separate from our buildBundle flag argument.
The launcher argument is required: bundle cannot derive library presence from
checkout IDs (a library has no agent clone). No role, authority or config schema
changes; library project entries are already validated and protected by M9.
## Mount and pin impact (inspection only)
No new imports in broker.mjs or tools.mjs; no RUNNER_FILES or sandbox mount
addition. runtime.mjs is host-only and already imports library index.mjs.
Bundle forwards only a boolean, never a root/mapping/slot/credential. The
existing import-closure tests are bus/tests/project-runtime.test.mjs and
seat/tests/sandbox.test.mjs. Grep found no literal sha256 pin for runtime.mjs
or bundle.mjs; bundle tests derive current source digests through SOURCES.
Library sources remain in the existing whole-directory release copy.
## Runs to schedule only after CLEAR
Set TMPDIR=/mnt/storage/scratch/tmp. Focused new bus/harness files first,
then existing library.test.mjs, library-sync.test.mjs, project-runtime.test.mjs,
harness creative/tools/spec-tools/bundle files and affected CLI launch tests.
Follow with the required whole bus/cli/harness suites and Sage's full shell gate.
No timing claim yet: use the repository/Sage per-file limits and record every
command, outcome, elapsed time and timeout before acceptance. No focused skip
or pattern run substitutes for whole-file gate evidence.
## Read-through findings and residuals
- Library get requires universe although the older design table omitted it;
section 3 isolation rule and C2-05a's accepted API are binding.
- Query type enum matches mapping.TYPES (five values); manuscript is a distinct
lookup and not a find type. Unknown host-path/commit/business/accept fields
are refused; explicit project is restricted to this business's library list.
- Async reads recheck the live holder and current project revision. A stale
read attempts sync, then takes the new served snapshot; a returned fetch
failure/divergence keeps serving the old pin. Busy sync uses the current pin.
Unreadable state, no served commit or unsafe configuration refuse. Query
and sync never hold a SQLite transaction over await.
- Processing halt refuses new agent lookup work and rechecks before returning
async results. Existing human library file/status/sync behavior is unchanged.
- Timer sync and reads still use C2-03a's 60-second contract. No timer tuning or
resource-limit schema is introduced by this row. Q222 creative reply settings
are in C2-06; no reply guard or writing capability is implemented here.
- The generic tool socket's existing 60-second client timeout can precede the
server's 120-second Git timeout. This row does not change the generic timeout;
a timed-out lookup is read-only and retryable, never a write/outcome claim.
- All tests are authored but unrun. No RED, GREEN, syntax, runtime performance,
sandbox, broker/harness suite, shell gate, claim or push evidence exists yet.
## Candidate review checklist (read-through only)
- [x] Five verbs mapped to real C2-05a methods, all require universe.
- [x] Default sole library or explicit owned project; no caller repo/commit.
- [x] Human-only file/status/sync gates kept; lookup holder rechecked.
- [x] Async socket dispatch includes lookup verbs through LIBRARY_VERBS.
- [x] Stale/unknown fetch syncs first; results use current immutable served pin.
- [x] Six/eight creative names per mos-1308; other roles retain their tools.
- [x] Launcher -> bundle -> typedTools passes presence without a library clone.
- [x] No imports added to sandbox source closure; no mount change.
- [ ] Execute new tests, RED characterization, full files/suites after CLEAR.
- [ ] Rebase after rows 253/216 land, under a new branch name.
- [ ] Claim/push only in Sage-authorized gap; Dewey review and Sage gate.
Candidate preservation commits are explicitly unrun under sage-2795; they are
not completion, green-suite or landing claims. No dependencies installed.
Correction: the first committed-range diff --check found a blank line at EOF
in the new library-tools test, missed while it was untracked. Removed in a
follow-up local commit; the complete range must be checked again. No tests ran.
## Shared-log receipts for Sage (sage-2815)
The shared logs are unchanged from the base in this row. Sage appends these
receipts in the landing sweep; they are preserved here, not in the manifest.
For `BUILD-LOG.md`:
```text
2026-10-22T16:36:30Z | Della row224 C2-05b #1725 preparation | Before: C2-05a queries exist at 4f3fbc25a but no broker lookup verbs or typed tools. Scope: broker/tools plus Sage-authorized runtime/bundle integration, tests and docs. Existing human-only library reads retained; five agent queries require a live holder and trusted configured source. Code/docs only under row253 gate; no Node/test/push/claim. Runtime ownership coordinated with Rocko219/Huey272.
```
For `docs/SESSIONS.md`:
```text
2026-10-22T16:36:30Z | Della (sage-2795 / sage-2804, Q220) | Row 224 C2-05b #1725, della/row224 base 4f3fbc25a | Read both pinned briefs and C2-05a source; preparing broker/typed tools and authorized host runtime/bundle seams. No queue command, Node, HTTPS Git, claim, test or push during Gander gate. Dewey reviewer; Rocko/Huey runtime coordination sent. Tests and integration verification deferred to CLEAR.
```
Local-only draft pins 19c0a7adf/7cf39c390 are superseded by the
log-free candidate. Saved as local branch della/row224-draft-with-logs before
rebuilding the unpublished candidate commit; no remote history changed.
## Dewey dewey-0852 and Sage sage-2819 — focused corrections
B1: inline freshness uses max(lastFetch, lastError.at), not lastFetch alone.
An error within 60 seconds counts as a recent attempt, so later lookups serve
the old served pin without another fetch. Keep inline sync: the brief requires
it and businesses without timed libraries have no sync timer. The extended
failed-fetch test makes reads at +1, +59,999 and +60,000 ms (one attempt total),
then +60,001 ms (second attempt). All reads must return the old commit.
Required mutant M-B1: remove lastError.at from recentAttempt. The repeated
reads must fail the attempted-count assertion. This mutant has NOT been run.
Residual (Sage will defer): the first inline fetch in each outage window can
still exceed the tool timeout (60 seconds, or the runner broker client’s
30 seconds). Bounding that fetch is out of scope for 224. Subsequent reads
are paced by persisted lastError.at. No claim of first-read outage latency.
N1: whole bus, cli and harness suites are required, plus the shell gate.
If the new thread-launch integration test skips due to sandboxProblem(),
name the skip; a skip is not launcher integration proof.
N2: cli/tests/thread-launch.test.mjs now depends on library/tests/helpers.mjs
mappingEntry() and library/tests/fixtures/mapping.json. Changes to that fixture
must rerun the CLI launch file.
N3: harness/tests/library-tools.test.mjs compares the type enum to mapping.mjs
TYPES, so adding a mapped type cannot silently leave the typed tool behind.
These edits are local code/docs only. No Node, RED/GREEN run, mutant, claim,
HTTPS Git or push. New local pin goes to Dewey/Mos/Sage for focused review.
+196
View File
@@ -0,0 +1,196 @@
# Row284 C2-05c — supporting scope and served-tree blob (#1789)
Owner Della; reviewer Dewey; gate Sage. Build start sage-3658/3660 (10-23).
Branch della/row284. Original prerequisite base Honker28346bad7ea3;
merge fc05bcbc3 carries22423816cf28 and2164f3fbc25a intact. Only conflict was
CLI thread-launch's added footer tests: retained both full test bodies and
assertions. No prerequisite code copied. Every prerequisite needs its own
review/mutants/landing under mos1351; rebase after283 lands.
## Execution status
UNTESTED. No Node, node--check, test or mutant run before Sage CLEAR. First
own commit is the authorized old-test migration; the implementation follows.
Sage authorizes an immediate untested push. No queue write or shared-log edit.
224 stays first;284 is critical to Bubba288. T4 for224 is not a CLEAR yet.
## Binding readings
Brief C2-05c at94c33a43c:1737-1789, Q226, sage2918 and mos1330 item3.
get defaults canon; explicit playground/supporting opt-in; mismatch null.
Sort full outgoing/incoming lists supporting, playground, canon before paging,
keep cross-scope links and universe filter. Manuscript stays canon/playground.
No all/array scope, write tool, guard, mapping or creative-name-count change.
## mos-1330 old-test migration (separate commit)
All paths below are packages/library/tests/query.test.mjs. Baseline file at
fc05bcbc3 matches216's file. Source assertions retained; negative calls receive
explicit scope too, so universe isolation cannot pass on default-scope denial.
Call/assertion 348 -> 348:
```js
// OLD
const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md' });
// NEW
const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' });
```
Call/assertion 350 -> 350:
```js
// OLD
assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md' }), null);
// NEW
assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' }), null);
```
Call/assertion 366 -> 366:
```js
// OLD
assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path }), null);
// NEW
assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path, scope: 'playground' }), null);
```
Call/assertion 367 -> 367:
```js
// OLD
const own = await get(src(ref), { universe: 'harbor', path });
// NEW
const own = await get(src(ref), { universe: 'harbor', path, scope: 'playground' });
```
Assertion 349 retained, OLD == NEW:
```js
assert.equal(rin.scope, 'playground');
```
Assertion 368 retained, OLD == NEW:
```js
assert.equal(own.scope, 'playground');
```
The null assertions at350 and366 are the complete OLD/NEW expressions above:
expectation remains null. Added one separate no-scope playground-get -> null
behavior test. It is unrun and expected to expose old get behavior until the
new scope implementation lands. This migration is authorized by mos1330 under
mos1272; no check removed or weakened. Dewey checks this list in manifest review.
## 283 surface and blob choice
Inspected actual46bad7ea3: optional editor.user is present. reader's parsed
file records and index entries still discard tree oid. In owned query.mjs use
the existing bounded listTree(repo, servedCommit) to obtain exact path oid,
not HEAD and not a user-supplied revision expression. No reader/index edit.
Tradeoff: one additional bounded tree listing per get; latency unmeasured.
Recheck after rebasing to283's actual landing.
## Intended owned paths
query.mjs; broker.mjs lookup branch; tools.mjs lookup schemas/descriptions;
library query.test.mjs/query-fixture.mjs; bus library-lookups.test.mjs;
harness library-tools.test.mjs; this BUILD.md. Other files are prerequisites.
## Gate
Dewey manifest approval, whole library/bus/harness suites and every
scripts/test-*.sh on Sage gate. Before review grep all package tests for enum
pins and canon/playground pairs. Keep shared logs out; send receipts to Sage.
No test or mutant evidence claimed. Manifest/digest follows complete code.
## Implementation head (UNTESTED, sage-3658)
- query scope accepts supporting for find/search/get. get defaults canon,
mismatch null. Manuscript passes a separate two-value scope allowlist.
- get returns blob from listTree(source.repo,index.commit) after scope/universe
filtering. The same index.commit supplies the text; source HEAD is irrelevant.
- Full outgoing and incoming link lists are stably sorted by supporting,
playground,canon before combining/paging. All visible cross-scope links stay;
universe/hybrid visibility still applies. Scope metadata isn't added to links.
- Broker only adds optional scope to library.get's strict fields. No new verbs.
- Typed scope expands find/search/get only; manuscript retains its own two-value
enum. Names/M3 counts remain the row224 six/eight, no write tools added.
- New tests: book/universe/draft supporting find/search/get; defaults/mismatch;
absent supporting prefix; source HEAD advances but each scope's blob equals
served:path; 300 outgoing plus300 incoming links ordered across bounded pages,
retaining cross-scope links and dropping other-universe links; hybrid source
stays canon-only; no all/array scope; manuscript rejects supporting; broker
scope/blob forwarding; typed enums and unchanged names. All unrun.
## Additional old assertion pin (brief Gate)
harness/tests/library-tools.test.mjs:49 at23816cf28 (line49, unchanged in this head):
OLD find scope enum ['canon','playground']; NEW ['canon','playground','supporting'].
This is the exact scope-enum pin the brief directs updating. Existing six/eight
name snapshots and TYPES equality assertion are unchanged. The new manuscript
pin intentionally remains ['canon','playground']. No other two-scope test pin
was found by rg over packages/*/tests at this build's prerequisite merge.
## Proposed mutation evidence (reviewer confirms final set)
All unrun. Require assertion failures with whole-file runs and pristine restore:
M1 remove supporting from query SCOPES -> supporting lookup cases.
M2 drop get's requested-scope comparison -> default/mismatch null cases.
M3 use source HEAD for blob lookup -> served-tree/advanced-HEAD equality case.
M4 remove link rank sort -> multi-page outgoing/incoming expected order.
M5 restrict links to requested scope -> cross-scope link counts/order case.
M6 drop sees() from visible/link filtering -> second-universe/hybrid cases.
M7 allow supporting in manuscript validator -> manuscript refusal case.
M8 omit get scope from broker fields -> real broker supporting get case.
M9 widen typed manuscript enum -> typed-scope separation case.
## Inspection and pending gate
No production imports change in broker/tools, so no new sandbox closure file
or mount. query.mjs is host library code and adds existing reader.listTree;
no new package/export/copy-path file. rg found the one old find scope pin plus
the intentional new manuscript two-scope pin. Whole library/bus/harness and
shell gates remain required. CLI integration/merge context is prerequisite224/
219, not this row's source delta; any changed gate expectation goes to Sage.
Node checks, tests, mutants and elapsed-time evidence remain absent pending
CLEAR. The first test-migration commit and this implementation are distinct.
Sage sweep receipt: Della row284 builds on28346bad7ea3 plus intact224 merge;
mos1330 old-test migration separate, supporting scope/blob implementation and
fixtures authored, completely UNTESTED. No shared-log/queue write or live action.
## Current assertion anchors after implementation imports
The separate migration commit0ed4fbab9 retains its complete OLD/NEW text
above. Adding only the fs/path imports in the implementation shifts anchors:
query.test.mjs:130: `const blendCh2 = await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path: 'Book One/Chapters/Chapter 02.md' });`
query.test.mjs:350: `const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' });`
query.test.mjs:351: `assert.equal(rin.scope, 'playground');`
query.test.mjs:352: `assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' }), null);`
query.test.mjs:368: `assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path, scope: 'playground' }), null);`
query.test.mjs:369: `const own = await get(src(ref), { universe: 'harbor', path, scope: 'playground' });`
query.test.mjs:370: `assert.equal(own.scope, 'playground');`
No result/null assertion changed; scope arguments carry the explicit opt-in.
## Dewey dewey-0941 static review — round2
Static scope fc05bcbc3..47db04eba CLEAN; not a final verdict or test evidence.
M1-M9 accepted as the final set. Optional M10 not added to this gate.
N2 taken: keep the existing canon-path supporting-scope mismatch assertion,
and ADD get(Book One/Support/Notes/Helper.md,scope:supporting) -> null under
the default mapping with no supporting prefix. The supporting-positive test
already returns this same path under withSupporting, so the added probe
pins the missing-prefix case directly. No assertion removed or weakened;
mos1330's separate migration commit and its retained assertions unchanged.
N1 proposed DEFERRED/follow-up for Sage: retain each tree oid in reader/index
entries so get can return blob without cat-file/whole ls-tree -r -l per page.
Current get's result is pinned correctly, but per-page cost grows with the
library. No performance measurement yet. This optimization needs reader/index
ownership and separate acceptance/performance evidence; do not silently expand
284's manifest or change its served-snapshot contract for it.
No Node, tests, mutants or syntax checks run in this round; hold still applies.
+211
View File
@@ -0,0 +1,211 @@
# Row 283 (#1788) C2-01d build notes (Honker)
Append-only. Design: DESIGN.md d2 16e809457, APPROVE webby-0172. Gate owner Sage, reviewer Webby.
## 2026-10-23 build on 45dca69f6 (sage-3577)
Branch honker/row283-build from origin/refactor 45dca69f6e4bce52834587f66f2ff33ba839ab8a. The old branch
honker/row283 (16e809457) held only the design, so its two commits were cherry-picked as 398314cf4
(d1) and 4f66dccde (d2). The DESIGN.md blob equals 16e809457's.
Commits:
- 331653b38: code, README and tests T1-T8.
- 28918824c: the survey.test:73 re-pin, alone (mos-1330 item 4).
- this file.
Nothing has run. No node, test or mutant ran during the build: Darkwing held the slot. Every count
below is a plan until Sage's CLEAR.
### Drift check (sage-3577)
`git diff --stat 94c33a43c 45dca69f6` (design base to build base) over the paths the tests read or
hash-pin:
- No change in packages/library, packages/business/src/business.mjs or
packages/business/tests/business.test.mjs.
- No test hash-pins a library file. bus library.test and library-sync.test import index.mjs, and
stack-copy.test only checks that index.mjs exists.
- webui library-cli.test changed one line and doesn't read scope. The `classify` in webui reads.mjs is
the business one, not the library's.
I redo the drift check on BASE..refactor before the in-review move.
### N3 (webby-0169): deepEquals of the mapping output outside packages/library
`git grep -n playground -- 'packages/*/tests/*' ':!packages/library/*'` and the files naming
`universes` give two files:
- business.test.mjs:20 compares `validateBusinessDocument(...).projects.books` with
`checkMapping(mappingEntry())`. That is output against output, so `supporting: []` shows up on both
sides. :27 compares only `.playground`.
- library-projects-protected.test.mjs compares file bytes (:31, :32) and change paths (:58), never the
mapping output.
No re-pin is needed outside library. Inside library, only the draft deepEqual at mapping.test:48 gains
`supporting: []`, as the design says.
### Q1 departure (sage-2923) and the :73 re-pin (mos-1330 item 4)
survey.mjs:65 counts `supporting: 0` and formatSurvey prints it. T8 is the only added test
(survey.test "C2-01d: with --mapping the survey counts supporting files").
mos-1330 item 4, packages/library/tests/survey.test.mjs:73, commit 28918824c:
- old: `'scopes canon 5, playground 1, unmapped 3; indexed 6',`
- new: `'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',`
N2: the pin gains only `supporting 0`. No other text on the line or in that test moved.
### Q2 tradeoff (webby-0169)
A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. That goes past
the brief, but it is the fail-closed reading, and it uses an existing code: classify checks supporting
first, so that playground prefix could never match. The cost is that she can't nest a playground under
a supporting folder, though she can nest the other way (a playground holding a supporting folder).
### Handoffs
- Q4 (webby-0169, sage-2930): the M12 delete rule ("A delete also needs the path to exist at the tip as
a 100644 blob", case2 DESIGN.md :308) is not in supportingPath. It binds row 288 (C2-02) via Gitea
#1793 comment 30738, with tests for a missing path and a non-100644 entry.
- A save with no `editor` refusing is C2-02's (DESIGN.md section 1).
### Choices the design left open
- The book loop is now `againstBooks(owners, list, code)` in mapping.mjs, called once per list. The
playground's messages and codes are byte-equal to before.
- A non-string path refuses `library-write-story-path` ("the path must be a string") as part of
check 1. A non-array `tree` is a TypeError (DESIGN.md section 3).
- Check 8 builds the tree's names as every path and every folder above it, keyed by
`normalize('NFC').toLowerCase()`. A name on the way refuses if its key holds any name other than
itself, so a tree that already holds case twins refuses a write to either (R7).
### Mutants (exact strings)
Each applies one replacement to the build tip. Run one at a time, and restore with `git checkout --`
on the one file before the next.
| Mutant | File | Replace | With | Killed by |
|---|---|---|---|---|
| M1 | src/scope.mjs | `const prefix = entry.supporting.find(p => inside(p, path));` | `const prefix = entry.supporting.find(p => inside(p, path) && !entry.playground.some(q => inside(q, path)));` | scope.test "supporting comes before playground", supporting.test "passes at book level" (both-lists path) |
| M2 | src/mapping.mjs | `if ((inside \|\| holding) && owner !== id)` | `if ((inside \|\| holding) && owner !== id && list !== 'supporting')` | mapping.test "each supporting prefix rule", business.test C2-04 |
| M3 | src/supporting.mjs | `for (const p of own) if (LINKS.has(modes.get(p)))` | `for (const p of own) if (false && LINKS.has(modes.get(p)))` | supporting.test "symlink or gitlink" |
| M4 | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.normalize('NFC');` | supporting.test "case or Unicode form" |
| M4b | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.toLowerCase();` | supporting.test "case or Unicode form" (NFD case) |
| M5 | src/supporting.mjs | `Buffer.byteLength(path, 'utf8') > MAX_BYTES` | `path.length > MAX_BYTES` | supporting.test "1025 bytes" |
| M6 | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[<>]/;` | mapping.test "editor is optional" |
| M6b | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f]/;` | mapping.test "editor is optional" |
| M7 | src/supporting.mjs | `if (place.scope !== 'supporting' \|\| place.universe !== universe)` | `if (place.scope !== 'supporting')` | supporting.test "outside the universe's supporting folders" |
| M8 | src/mapping.mjs | `if (sid === pid && s !== p && holds(s, p))` | `if (false && sid === pid && s !== p && holds(s, p))` | mapping.test "each supporting prefix rule" (Workshop case) |
(The `\|` in the table is an escaped `|` in the source text.) Each mutant runs the one test file named,
plus business.test for M2, under `timeout -k 10 600`. A mutant passes the gate only when its run is red
on the named test and the restored file's sha256 equals the build tip's.
### Gate plan (on Sage's CLEAR)
1. The touched files whole, each under `timeout -k 10 600`: library mapping, scope, supporting and
survey tests, and business.test.mjs. Counts go below.
2. M1-M8 as above.
3. The full gate, under mos-1275 timeouts: `node --test packages/library/tests/` and
`node --test packages/business/tests/` each under `timeout -k 10 1800`, and every scripts/test-*.sh
under `timeout -k 10 300`. pipefail and tee, with skips named (test-task Docker groups).
4. Before in-review: grep packages/*/tests for a 64-hex pin on any changed file and for the changed
message text, then the drift check on BASE..refactor.
## 2026-10-23 Webby early look (webby-0227, static, not a verdict)
- E1: business.test.mjs is an existing test file with a 4-line addition after the playground refusal.
The in-review request lists it next to survey.test:73.
- E3 fixed: supportingPath now runs the string check before `JSON.stringify`, so a BigInt (or any
non-string) path refuses `library-write-story-path` instead of throwing a TypeError.
supporting.test's string case now covers 7, null, undefined, 1n and an array.
- E2 open: `editor` takes surrounding whitespace, and git trims it at commit, so the saved ident and
the commit ident can differ. DESIGN.md section 1 says "non-empty after trim". Refusing
`value.trim() !== value` would change that approved rule, so it went to Sage instead of into the
build.
## 2026-10-23 E2 departure (sage-3601)
A departure from DESIGN.md section 1 (which stays unedited). The design says each `editor` value is
"non-empty after trim". Sage ruled that surrounding whitespace refuses too, because git trims an
ident at commit, so a padded value would save one name and commit another. mapping.mjs now adds
`if (value.trim() !== value) bad(`editor.${key} must not start or end with whitespace`)` after the
IDENT_BAD check. `trim()` covers Unicode whitespace, so U+00A0 and U+3000 refuse as well. Inner
whitespace is kept ("Her Middle Name" passes).
T3 gains a case per key for leading, trailing and both-ends spaces, U+00A0 and U+3000, plus one inner
whitespace pass.
M6 grows a third part:
| Mutant | File | Replace | With | Killed by |
|---|---|---|---|---|
| M6c | src/mapping.mjs | `if (value.trim() !== value) bad(` | `if (false && value.trim() !== value) bad(` | mapping.test "editor is optional" (padded cases) |
Fixture grep: `git grep -nE "\beditor\b|\bauthor\b" -- 'packages/library/tests/*' 'packages/business/tests/*'`,
leaving out mapping.test.mjs, finds nothing. packages/business/tests has no fixtures directory. The
library fixture mapping.json has no `editor`. The positive control (`vaultPrefix` in mapping.json)
matches once. No fixture maps an editor or author with surrounding whitespace.
## 2026-10-23 E4 (webby-0230, sage-3606), same departure as E2
Claimed: `queue move 283 in-progress`, op honker-283-start, rev 3642, 19:10:49Z (briefed→in-progress).
Git's ident.c `crud()` strips any byte <= 0x20 and `, : ; < > " \ '` from both ends of an ident. I
checked that with git 2.55.0: `Jane,` `"Jane"` `'Jane'` `Jane;` `Jane:` `Jane\` and `,Jane` all
commit as `Jane`, while `Jane.` and `Ja,ne` are kept. mapping.mjs now takes the set from crud()
rather than from that list: `IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/`.
The E2 check became `if (value.trim() !== value || IDENT_CRUD_END.test(value))`, which refuses
library-mapping-invalid with "editor.<key> must not start or end with whitespace or any of , : ; " ' \".
`trim()` still catches Unicode whitespace (U+00A0, U+3000), which crud() leaves alone.
Inner characters: IDENT_BAD (`/[\u0000-\u001f\u007f-\u009f<>]/`) already refuses `<`, `>`, newline,
tab and every other control anywhere in the value. T3 now pins that at the start, the end and the
middle. Inner `, : ; " ' \` are kept, since git keeps them, and T3 pins that too.
Tests:
- T3 has one case per character in [space, U+00A0, U+3000, `,`, `:`, `;`, `"`, `'`, `\`], at the start
and at the end, for each key. `<`, `>`, newline and tab are pinned at the start, end and middle.
Four accepted names have inner crud characters.
- New round-trip test, "an accepted editor round-trips through a real git commit unchanged". It
commits in a scratch repo with editor `Doe, Jane Q.` / `[email protected].` as author and
committer, with GIT_AUTHOR_* and GIT_COMMITTER_* stripped from the env, and asserts `git log`
shows exactly the saved values. I preflighted that commit with git 2.55.0 by hand: both values came
back unchanged.
M6c now replaces the whole end check (this supersedes the earlier M6c row):
| Mutant | File | Replace | With | Killed by |
|---|---|---|---|---|
| M6c | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (false)` | mapping.test "editor is optional" (end cases) |
| M6d | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (value.trim() !== value)` | mapping.test "editor is optional" (`,` `:` `;` `"` `'` `\` cases) |
## 2026-10-23 E5 (webby-0236, not blocking), fixed
The round-trip test stripped only GIT_AUTHOR_* and GIT_COMMITTER_*. With GIT_DIR, GIT_WORK_TREE or
GIT_INDEX_FILE set (inside a hook, say), GIT_DIR beats `-C root` and the commit would land in the
outer repo, and the test read global and system config. It now deletes GIT_DIR, GIT_WORK_TREE,
GIT_COMMON_DIR and GIT_INDEX_FILE (as queue/tests/helpers.mjs gitEnv does) and sets
GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1 (as ledger.test.mjs does). The `-c user.*`
ident still applies. Test only; no mutant changes.
## 2026-10-23 sage-2925: optional editor.user (sage-3649)
sage-2925 (recorded in ludwig/row286-r2 agents/ludwig/work/queue-286/BUILD.md, Q4) ruled that row 283
adds an optional `editor.user`, her Gitea login for the `library-human` slot, which row 288 (C2-02)
passes to `humanCredential(context, {user})`. It reached this row after the DESIGN.md d2 approval
(ludwig-0068, bubba-0009, sage-3649), so it departs from DESIGN.md section 1 ("exactly the keys
`name` and `email`"). DESIGN.md stays unedited.
- mapping.mjs: `only(entry.editor, ['name', 'email', 'user'], 'editor')`. When present, `user` must be
a string matching USER (`/^[A-Za-z0-9_.-]{1,100}$/`, the entry `user` check), else
library-mapping-invalid "editor.user must be a plain account name". The output carries `user` only
when given. An absent `editor.user` refusing library-human-missing is 288's (bubba-0009).
- README: the `editor` line names `user` and the end-character rule from E2/E4.
- Tests: T3 (mapping.test "editor is optional") gains one accepted case (`her.login-1`, kept in the
output) and refused cases for '', an inner space, '@', 101 characters, 7 and null. They go in
mapping.test with the other editor cases, not in the new supporting.test, which tests write paths.
- No test outside mapping.test reads `editor` or pins mapping.mjs or README (git grep).
| Mutant | File | Replace | With | Killed by |
|---|---|---|---|---|
| M6e | src/mapping.mjs | `if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' \|\| !USER.test(entry.editor.user)))` | `if (false)` | mapping.test "editor is optional" (editor.user refusals) |
| M6f | src/mapping.mjs | `...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {})` | `...({})` | mapping.test "editor is optional" (`her.login-1` kept) |
+191
View File
@@ -0,0 +1,191 @@
# Row 283 (#1788) C2-01d design, draft 2 (Honker)
Brief: docs/plans/2026-10-20_case2-rows.md "C2-01d" at refactor 94c33a43c. Sources: the design at
agents/gosalyn/work/case2/DESIGN.md b68d79663, the ruling after r11 (:207), the M12 paragraph (:300),
1.1 (:755) and 1.2 (:817). Base 94c33a43c. Reviewer Webby, gate owner Sage. Nothing is built yet. The
build waits for 282 to land (sage-2906).
## 1. Mapping (packages/library/src/mapping.mjs)
`supporting`:
- Every universe kind takes an optional `supporting` list: plain `['name','books','playground','supporting']`,
hybrid adds it to its seven keys, and a draft gets `['kind','name','playground','supporting']`.
- Each value is checked as a playground prefix is: a string ending in `/` whose body passes `folder()`.
- The output carries `supporting` on every universe, `[]` when absent, the way a draft carries
`books: []`. classify then needs no `?? []`. The business writer writes the raw document
(organization.mjs:195), not this output, so no business file changes shape.
- The prefix rules, across every entry (ruling after r11):
- A supporting prefix holding another supporting prefix, or one listed twice, refuses
`library-mapping-invalid`.
- A prefix equal to one of its own entry's book folders, or holding one, refuses `library-mapping-invalid`.
- A prefix inside or holding another entry's book folder refuses `library-supporting-other-universe`.
- Level comes from where the prefix sits, as for the playground: inside one of its own entry's books
is book level, and outside every book is universe level. A draft's prefixes are all universe level.
- Between the two lists (my choice, Q2):
- A supporting prefix may equal a playground prefix of the same entry. That folder is writable.
- A playground prefix may hold a supporting prefix of the same entry. The inner folder is supporting
and the rest stays playground.
- A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. classify
checks supporting first, so that playground prefix could never match.
- A supporting prefix equal to, holding or inside a playground prefix of another entry refuses
`library-mapping-invalid`. Otherwise one path would have two universes.
- The book loop at mapping.mjs:128-140 becomes a helper that both lists call with their own code and
label. The playground's messages and codes stay byte-equal, so no existing test text moves.
`editor`:
- The entry takes an optional `editor`. When present, it is an object with exactly the keys `name` and
`email` (via `only()`). Each value is a string that is non-empty after trim and has no control
character (`[\u0000-\u001f\u007f-\u009f]`), `<` or `>` (webby-0169 R1: git drops them from an ident
and commits under a different name, so C2-02 would never see a failure). Anything else refuses
`library-mapping-invalid`.
- The output carries `editor: { name, email }` only when present, as `user` and `vaultPrefix` do. A save
with no `editor` refusing is C2-02's job.
## 2. Scope (scope.mjs)
- `classify` gets a supporting loop before the playground loop (design 1.2). It returns `place(...)`
with `{ scope: 'supporting', universe, book }`. The book is the book folder holding the prefix, or
null at universe level and in a draft. The type and folder come from the first folder under the
prefix, as for the playground.
- The `.md` check stays first, so a `.canvas` or `.base` under a supporting prefix stays unmapped.
- The comment above `classify` names the three prefix scopes and the order.
## 3. supportingPath (new supporting.mjs)
`supportingPath(mapping, universe, path, tree)` returns `path` or throws
`LibraryError('library-write-story-path', ...)`. It is pure and reads no git.
- `tree` is the array `listTree` returns (reader.mjs:26, `{ mode, type, oid, size, path }`), so the
C2-02 broker passes its listing as it is (Q3). A non-array `tree` throws a TypeError, because that is
a caller bug, not a path class.
- The checks run in this order, each with its own message naming the class:
1. The path must be a string of at most 1,024 UTF-8 bytes (`Buffer.byteLength`).
2. It must have no control character, using the same control class as `editor` (not `<` or `>`).
3. It must have no backslash.
4. It must not be absolute (a leading `/`).
5. It must have no empty, `.` or `..` segment. That also catches `//` and a trailing `/`.
6. `classify(mapping, path)` must give scope `supporting` and that same `universe`. This one check
covers an unknown universe, an unmapped path, a `.canvas`, `.base` or attachment, a canon or
story path, a playground path that isn't also supporting, and another universe's prefix. The scope
rule then has one source, and M1 below hits both.
7. Every proper prefix of the path (`a`, `a/b`, ...) and the path itself must not be in `tree` with
mode `120000` (symlink) or `160000` (gitlink).
8. No prefix of the path, and not the path itself, may equal a tree path or a tree directory
(derived from the entries' paths) after `normalize('NFC').toLowerCase()` on both sides, unless
it equals that tree path exactly. NFC as well as case, because APFS treats NFC and NFD forms of
a name as one file (webby-0169 N1).
Directories are checked too, because the folders under a supporting prefix are hers to name. A
path in a tree that holds both `a.md` and `A.md` refuses, which matches R7.
- The refusal messages quote the path with JSON, except for the control-character class, whose message
leaves the path out.
- The delete rule ("A delete also needs the path to exist at the tip as a 100644 blob", M12) isn't in
the brief's supportingPath list. It stays with C2-02's delete (Q4).
The C2-02 brief doesn't name it (only design :308), so BUILD.md carries it to C2-02 under Handoffs
(webby-0169 Q4).
- index.mjs exports `supportingPath`.
## 4. README (packages/library/README.md)
- Mapping: `supporting` and its rules, the cross-list rules, and `editor`.
- Refusals: a `library-supporting-other-universe` row, and the new cases in the
`library-mapping-invalid` row.
- Scope: supporting first, then playground, then canon.
- A new section, "Agent write paths", for supportingPath and its classes.
## 5. Callers of classify outside files owned (Q1 for Sage)
- survey.mjs:65 builds `{ canon: 0, playground: 0, unmapped: 0 }` and increments
`scopes[classify(...).scope]`. A supporting file adds `supporting: NaN`, and formatSurvey (:92) never
prints it. I propose a departure: add `supporting: 0` and print it, so the line becomes
`scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6`. That re-pins survey.test:73 and
adds a count test. Without it, the survey reports garbage once she marks a folder. The departure
covers two files, packages/library/src/survey.mjs and packages/library/tests/survey.test.mjs.
- No change in the rest, each for a reason:
- reader.mjs:183 skips only unmapped, so supporting files are indexed like playground files.
- R5 (:207) flags only canon to playground, as design 1.3 R5 says. A canon link to a supporting file
raises nothing.
- render.mjs:23 gives supporting a null badge. The supporting badge belongs to C2-10b (design :1415).
- mirror.mjs:246 refuses only unmapped. Reading supporting files by scope is C2-05c's.
- No bus or webui source filters on scope (git grep at 94c33a43c).
- M9 already protects the whole `projects` key from agent edits (library-projects-protected.test). No
test is added there.
## 6. Tests
- mapping.test:
- T1: `supporting` at book level, at universe level, in a hybrid and in a draft. The output carries
it, with `[]` when absent. The draft `deepEqual` at :48 gains `supporting: []`.
- T2: one test per prefix rule.
- These refuse `library-mapping-invalid`: no trailing `/`, a bad folder, one holding another, one
listed twice, one equal to its own book, one holding its own book, one holding a playground
prefix, and one overlapping another entry's playground.
- These refuse `library-supporting-other-universe`: one inside another universe's book, and one
holding another universe's book.
- An equal same-entry playground passes.
- T3: `editor` passes, and absent passes. It refuses `library-mapping-invalid` for a non-object, an
array, an extra key, a missing key, an empty or blank value, a non-string, and a control character,
`<` or `>` in either value.
- scope.test, T4:
- Supporting at book level, universe level and in a draft each give their universe and book.
- A folder in both lists is `supporting`, and a playground-only folder is `playground`.
- A supporting `.canvas` or `.base` is unmapped.
- supporting.test (new):
- T5 passes at book level, universe level, in a draft, in a both-lists folder, and at exactly 1,024 bytes.
- T6 has one refusal per class against a fixture tree with a symlink, a gitlink and a case variant.
The classes are:
- unmapped, `.canvas`, `.base`, an attachment, canon, playground-only, another universe's
supporting prefix, and an unknown universe;
- absolute, `..`, `.`, `//`, a trailing `/`, a backslash, a C0 control and DEL;
- a symlink folder, a gitlink folder, and a symlink at the path itself;
- a case-variant file, a case-variant folder, and an NFD-form variant of an NFC tree name;
- 1,025 bytes made with a multi-byte character, so bytes and characters differ.
- Each asserts the code `library-write-story-path`.
- business.test: T7 adds one assertion that `validateBusinessDocument` refuses
`library-supporting-other-universe`.
- survey.test (only if Q1 is approved): T8 counts one supporting file, and :73 is re-pinned.
## 7. Mutants (exact strings go in BUILD.md)
| Mutant | Change | Killed by |
|---|---|---|
| M1 | classify checks playground before supporting (the brief's mutant) | T4 both-lists, T5 both-lists |
| M2 | the other-universe fail for supporting is dropped | T2 |
| M3 | the symlink and gitlink check is dropped | T6 |
| M4 | the case check is dropped | T6 |
| M4b | check 8 drops `normalize('NFC')` | T6 NFD variant |
| M5 | the byte length uses `path.length` | T6 1,025 bytes |
| M6 | the `editor` control-character check is dropped | T3 |
| M6b | the `editor` `<` and `>` check is dropped | T3 |
| M7 | supportingPath skips the universe match | T6 other universe |
| M8 | the supporting-holds-playground check is dropped | T2 |
## 8. Gate
- Webby approves the manifest.
- The library and business suites and every scripts/test-*.sh are green on Sage's gate.
- Timeouts (mos-1275): each package `timeout -k 10 1800`, each script `timeout -k 10 300`, each single
file and mutant `timeout -k 10 600`. These come from my row 256 gate at 10:34Z.
- Before in-review, grep packages/*/tests for 64-hex pins on the changed files and for the changed
message text, then drift-check BASE..refactor.
## 9. Questions
- Q1 (Sage): approve the survey.mjs and survey.test.mjs departure in section 5?
- Q2 (Webby): the cross-list rules in section 1?
- Q3 (Webby): `tree` as the `listTree` array?
- Q4 (Webby): the delete's 100644 check stays with C2-02?
- Q5 (Webby): `editor` refuses only blank values and control characters, as the brief says. `<` and `>`
would break a git ident. I'd leave them to C2-02's commit step unless you want them refused here.
## 10. Draft 2 (webby-0169)
- R1 folded: `editor` refuses `<` and `>` too, with T3 cases and mutant M6b.
- Q2 accepted. BUILD.md records the tradeoff: refusing a supporting prefix that holds a playground
prefix goes past the brief, but it is the fail-closed reading and uses an existing code. The cost
is that she can't nest a playground under a supporting folder, though she can nest the other way.
- Q3 accepted. Q4: carried to C2-02 under Handoffs in BUILD.md, and Sage was asked to add it to C2-02's brief.
- N1 folded into check 8 (NFC and case), with an NFD case in T6 and mutant M4b.
- N2: the survey.test:73 pin gains only `supporting`, and the count test is the only addition
(mos-1330, its own commit).
- N3: before the build, re-grep tests outside packages/library for a `deepEqual` of the mapping output
on the build base.
-124
View File
@@ -1,124 +0,0 @@
# Row 217 (Case 2 C2-06): build notes
Brief: docs/plans/2026-10-20_case2-rows.md, section C2-06, at base 45dca69f6.
Builder: Launchpad until 2026-10-23 19:09Z. Then Mos moved coding to Codex seats (mos-1500, Q245) and
the row went to Beakley on beakley/row217.
## State at handoff
All code in this commit is UNTESTED. No node, no test and no mutant has run on it. No test file is written.
`packages/harness/src/runner.mjs` is not started.
Done, untested:
- `packages/library/src/limits.mjs` (new, pure): `REPLY_LIMITS`, `resolveReplyLimits(read)` gives
`{requireCitation, citationsPerReply, fallback, unreadable}`, `checkReplyLimits(value)` gives a clone or null.
An unreadable business file forces `requireCitation` true. A key falls back to its default when
`replyLimits` is not an object or the key holds an invalid value.
- `packages/library/src/guard.mjs` (new): `GUARD_CODES` in design 5.2's table order, `FIXED_TEXTS`,
`WEB_CITE_DEFINITIONS`, `REASON_MAX` (300), `GuardError`, `parseReply`, `checkReply`, `libraryReader`,
`lastCommits`, `guardReply`.
- `packages/library/src/index.mjs`: exports for both.
- `packages/business/src/business.mjs`: `replyLimits` is a known top key. The validated output doesn't carry it.
- `packages/business/src/organization.mjs`: the M11 fence `replyLimitsFence(old, document, actor)` compares
against `old.human`, refuses the tester and any `<definition>.<instance>` of `old.roles`, and is called for
both `document` and `written`. `setReplyLimits` and `readRaw` are exported.
- `packages/bus/src/broker.mjs`: the guard call, the page store, the check event, `library.limits.set`,
`library.status` gains `replyLimits`, `role.claim` gains `library`, `thread.messages` gains `check` and `last`.
- `packages/bus/src/server.mjs`, `client.mjs`: `BusError.reason` (sage-3599).
- `packages/bus/src/runtime.mjs`: guard deps and the `library.limits.set` dispatch.
- `packages/cli/src/limits.mjs`: `limits replies --file LIMITS`.
Left:
- `packages/harness/src/runner.mjs`: the C2-07 runner work (see "Runner plan" below).
- `packages/cli/README.md`: a line for `limits replies`.
- Every test in the brief and the rulings below, then the mutants.
- The rebases onto rows 283, 216 and 285, with the drift checks below.
- This file's gate results and the manifest.
## Rulings in force
sage-3595:
1. Supporting scope: a stub classify test now. Before in-review, rebase onto row 283's landing
(honker/row283-build bd2c1c4ea when ruled) and add two real-fixture tests: a supporting cite with
`as: "canon"` refuses `playground-cited-as-canon`, and the stored check result names `supporting`.
2. runtime.mjs is approved for the guard wiring and the one `library.limits.set` branch. List its hunks here,
name it in the manifest, and drift-check it after the 216 and 285 rebases.
3. The check result is an `action.allowed` event with `action: "message.checked"`. The events kind CHECK at
`packages/bus/src/migrations/3b.sql:9` has no `message.checked`, so a new kind would be a schema step.
The event and the message INSERT share one transaction: a mutant that splits them must fail.
4. readLibrary at the cited commit stands in for the C2-05a index. On the 216 rebase, compare 216's index
set with readLibrary's for the fixture and record the result here.
5. The retry is for a library business only. Tests: a JSON body between 32,768 characters and 64 KiB gets
the retry; outside a library business nothing changes.
6. Old `role.claim` pins stay as they are.
7. `render.mjs:6` and `render.test.mjs:58` belong to row 225. Leave them.
8. `thread.messages` rows that carry `check` get `last` per path cite: the file's last commit at the served
tip, one git call per distinct path per read. Test with a fixture file committed after the cite.
sage-3599: `BusError` gets an optional `reason`. Only the citation guard's refusals set it. The envelope
carries `reason` only when it is set. It is the guard's own text, never a quote, file text, url or a path
other than the cited one, at most 300 characters. Tests: a guard refusal reaches the runner with code and
reason; a refusal without a reason has an envelope byte-identical to today's, checked against an existing
pin; a mutant that drops the client.mjs pass-through fails.
sage-3602: store `file` per path cite from the same git log call as the trailer. Test with a cited commit
that didn't touch the file, so `file` differs from the cited commit, and `last` equals `file`.
## The check-result contract with row 225 (launchpad-0118, launchpad-0120, morgana-0200, sage-3596)
- Stored on the `message.checked` event: `{citations: [...]}` in body order.
- A path cite: `{scope, agent, file}`. `scope` is the classify scope. `agent` is the cite's file's last
commit's `Mosaic-Agent` trailer at the cited commit, or null. `file` is that commit's 40-hex id, or null.
- A url cite: `{scope: "web", agent: null}`.
- On `thread.messages`, each path cite gains `last`: the file's last commit at the served tip (40-hex or null).
The Console compares `last` with `file`.
- Rows without `check` are unchanged. No `changed` boolean. The webui passes rows through unchanged.
## Hunks outside the brief's file list
- `packages/bus/src/runtime.mjs`: imports `setReplyLimits`; the library deps gain `guard`, `replyLimits`
(organizationWriter `readRaw`, or `{readable: false}` with no organization), `last` and `GuardError`;
the organization dispatch gains `library.limits.set`.
- `packages/bus/src/server.mjs`: a `refusal(e, other)` helper above serve's doc comment builds every
BusError envelope and adds `reason` only when set. The synchronous request path accepts a Promise result
(the guard is async), with a 120 s timeout, and releases `own` when it settles.
- `packages/bus/src/client.mjs`: `new BusError(r.error, reason)` with `reason` a string cut at 300.
## Decisions and their tradeoffs
- The guard runs async outside the SQL transaction, then re-enters request() through `#guardPass`, so
message.send's insert and the check event stay one synchronous transaction.
- The guard runs before message.send's own checks, so a guard code can come before an authority code.
- The check event id is derived from the message id (a UUID-shaped sha256), so `thread.messages` finds it
through the unique index on `events.id`.
- With no organization configured, `replyLimits` reads as unreadable, so it fails closed.
- With more than one library project, the guard uses the first in the business file's order.
- A library business without guard deps refuses guarded sends with `project-not-configured`.
- The page store is per cap: 20 pages of 200 KB, kept only in a library business, dropped at session
delete or disconnect and at run end.
- The CLI `replies` verb reads the revision itself from `limits`.
- Guard reasons never carry a url or a quote (sage-3599).
## Runner plan (not built)
`role.claim`'s reply is discarded at `runner.mjs:462`; keep it and read `library === true`. In a library
business, at the reply site (`runner.mjs:582-587`):
- Fixed texts `(the session gave no answer)` and `(the turn failed)`; the reason stays in the run log.
A fixed text goes with no note, and `unslop.sent()` is not called, so the note waits.
- A body that parses as a JSON object with a string `text` gets `unslop.note()` appended to `text`, is
serialized again and is never clipped. A plain body keeps `clip(body) + unslop.note()`.
- On a refusal whose code is in `GUARD_CODES`, or `request-too-large` from the client, or the broker's
`invalid-request` from `string(a.body, 32768)` for a body over 32,768 characters (ruling 5; the runner
can tell it by the body length it sent), run one new turn in the same
session naming the code and `err.reason`, or the 64 KiB limit. Bracket it with usage like other turns.
- After the retry: a table code sends `(no answer passed the citation check)`, too large sends
`(the answer was too long to send)`. One retry per answer, whatever the cause.
- `runTurn`'s output buffer holds up to 4 * BODY_MAX characters, so a reply over 64 KiB can reach the send.
- Outside a library business nothing changes.
## Mutants planned
- The M11 writer check removed.
- `document.human` compared instead of `old.human`.
- The check event split from the insert transaction.
- The client.mjs reason pass-through dropped.
+184
View File
@@ -0,0 +1,184 @@
# Row 216 build notes (C2-05a, lookup index and queries, #1717)
Phase A only: code, tests and this file. No test, suite or mutant has been run. `node --check` passed on every changed `.mjs`.
- Branch: `researcher/row216`
- Base: `d372102882d6858fa77af4aee453f866c886ad5c` (origin/refactor)
- Design: `agents/gosalyn/work/case2/DESIGN.md` on origin/gosalyn/case2-design, section 3 (table at lines 625-631, bullets 633-641), 1.2-1.4, B12, D2
- Brief: `docs/plans/2026-10-20_case2-rows.md`, C2-05a at lines 320-357
## Files
| Path | Status |
|---|---|
| `packages/library/src/index-build.mjs` | new |
| `packages/library/src/query.mjs` | new |
| `packages/library/src/index.mjs` | edit: 2 export lines added |
| `packages/library/tests/query-fixture.mjs` | new, test helper |
| `packages/library/tests/query.test.mjs` | new |
| `packages/library/tests/index-build.test.mjs` | new |
| `agents/researcher/work/queue-216/BUILD.md` | new (this file) |
No existing test or source file changed beyond `index.mjs`.
## Design rows and where each is tested
| Design row | Code | Tests (query.test.mjs unless noted) |
|---|---|---|
| `library_find` | `find` | find is bound to its universe; every result carries its scope; long list pages; large items page under the cap |
| `library_get` | `get` | hybrid gets a source file; get lists visible links both ways; get pages its lines |
| `library_search` | `search` | search returns scoped line hits; item that cannot fit alone is refused |
| `library_manuscript` | `manuscript` | one chapter at most 400 lines; refused without chapter; hybrid reads a source chapter |
| `library_universe` | `universeInfo` | universe info reports a hybrid's sources, constraints, notes; unknown universe refused |
| Hybrid semantics (1.4) | `sees` | a hybrid sees its sources canon minus exclusions, no playground |
| Unmapped never appears | index build + `sees` | unmapped files never appear (query.test); index holds mapped files only (index-build.test) |
| Per-commit cache (3) | `indexFor` | same commit, path, mapping return one index; different path, mapping or commit get their own; eight-entry bound; failed read not kept; unchecked mapping refused (index-build.test) |
| Rebuild time (3, C2-05a gate) | `indexFor` | 3,000-file rebuild, time reported with `t.diagnostic`, no threshold asserted |
| REPLY_CAP parity | `QUERY_REPLY_CAP` | equals `REPLY_CAP` from harness tools.mjs |
## Choices where the design table is silent or differs
These are departures or gaps. Each is built as stated, so Sage can rule before Phase B. A ruling that changes one of them is a small edit.
1. **Paging arguments (gap a).** The table has no offset. `find` and `search` take an optional `offset` (a position in the filtered list, default 0). A reply carries `next`: the position of the next item, or `null`.
2. **`library_get` has no universe (gap b).** Built with a required `universe`. Without it the reply could not be isolated. A missing file and a file the universe cannot see both return `null`, so a caller cannot tell them apart. `get` also takes an optional `from` line.
3. **`get` over the cap (gap c).** Lines page by `from` with `next`. `links_out` and `links_in` come back whole on every page. If the frontmatter and links alone exceed the cap, the call is refused with `query-item-too-large`. A hub note with many backlinks may be refused. This needs a ruling: link paging, or accept the refusal.
4. **`manuscript` without a chapter (gap d).** Refused with `query-chapter-required`. The design names no list form. A chapter name that matches two visible files is refused with `query-chapter-ambiguous`.
5. **`search` excerpt (gap e).** The excerpt is the whole line, not a truncation. A line that alone exceeds the cap is refused. Truncating it would cut an item.
6. **`exclude` form (gap f).** `checkMapping` accepts only a folder path for `exclude` (`folder()`), so an exclusion is a path prefix. There is no type or source form to build. Confirm.
7. **Pin constraints.** `pin` does not hide any file. It is reported by `universeInfo` only. Confirm.
8. **Playground through `get`.** `scope` applies to lists. `get` by an explicit path returns a playground file of the caller's own universe. Confirm.
9. **Links.** A link into a file the universe cannot see is dropped from `links_out` and `links_in`, in line with N1. Links from a visible file that point into another universe are kept in the index (the reader keeps them under R6), and the query layer drops them for universes that cannot see them.
10. **`find` matching.** Case-insensitive substring over the front matter `name` and the file name. An empty `query` matches every file in scope.
11. **Cache key.** `repo`, `commit` and a sha256 of the canonical mapping. Keying by commit alone would return the wrong scopes after a mapping change. The cache holds eight entries, least recently used out. Failed reads are not kept.
12. **`universeInfo`.** Reads only the checked mapping and the served commit. It builds no index.
13. **Reply commit.** Every reply carries the index commit, which is the served commit passed in.
## Provisional refusal codes (pending Sage approval)
The brief says new refusal codes need Sage's approval first. These are provisional and must not be treated as settled:
- `query-argument-invalid`
- `query-universe-unknown`
- `query-chapter-required`
- `query-chapter-ambiguous`
- `query-item-too-large`
Reused, existing codes: `library-mapping-invalid`, plus the reader's `library-git`, `library-commit` through `indexFor`.
## Grep results (grep only, not a test run)
- Test references to the changed paths: `packages/library/tests/*.mjs` import `../src/index.mjs` and `../src/mapping.mjs`. `packages/bus/tests/library.test.mjs` and `library-sync.test.mjs` import the library index as a namespace. Neither enumerates its exports, so the two new export lines break no assertion.
- `packages/cli/tests/stack-copy.test.mjs:52` checks that `packages/library/src/index.mjs` exists, and it still does.
- 64-hex pins: none in `packages/library/tests`, none on `index.mjs`, `query.mjs` or `index-build.mjs`.
- `scripts/test-*.sh`: no reference to the new modules.
## Flag for Sage
- `packages/library/tests/helpers.mjs` `repo()` builds its fixtures under `tmpdir()`, which is `/tmp`. The launcher rules forbid that. The new tests do not use it; they use `tests/query-fixture.mjs`, which builds under `/mnt/storage/scratch/tmp`. The existing helper should be fixed in a separate row, not here.
## Phase B commands (not run)
Run only after Sage sends "slot clear". Set the scratch TMPDIR first, per the launcher rule.
```
cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-aa3cc98787bc5152c/packages/library
export TMPDIR=/mnt/storage/scratch/tmp
node --test tests/query.test.mjs tests/index-build.test.mjs
node --test tests/*.test.mjs
```
Also run `packages/bus/tests/library.test.mjs` and `library-sync.test.mjs`, since they import the index.
## Status
Not yet verified. Nothing has been tested and nothing is committed. The working tree holds the Phase A changes. AGENTS.md invariant 8 allows a commit only after the applicable suites are green, so the commit waits for Phase B. The dispatch asked for a local commit in Phase A; Sage to confirm that the commit waits for Phase B.
## Round 2 (2026-10-22, sage-2708, sage-2716): rulings applied, rebased onto aca16404c
Added after the text above; nothing above is edited. "Nothing is committed", "Provisional refusal codes",
`query-chapter-required` and the tmpdir flag above were true when written and are superseded here.
- Branch `researcher/row216-r2`, one local commit on origin/refactor `aca16404c`. Row 268 (C2-01c) is in
that base. Row 253 has not landed; the work rebases onto 253's tip before any gate. Not pushed, not run.
- Rulings: `agents/sage/work/queue-216/RULINGS.md` at da9555ca2, rulings 1-13 and the tmpdir flag.
- Drift check, d37210288..aca16404c against my paths: the library files that changed are `mapping.mjs`,
`scope.mjs`, `README.md` and the mapping and scope tests. None is a file I add or edit. `query.mjs` reads
the checked mapping through them, so the whole library package runs in phase B.
### What changed from phase A
| Ruling | Change in `query.mjs` | Test |
|---|---|---|
| 3 `get` over the cap | `links_out` and `links_in` page together with `links_from`; every reply carries `links_next`, `next` and the front matter. A page with lines left carries at least one line. Links take at most half the cap while lines remain. `query-item-too-large` is left for front matter alone or one line alone (or one link alone) | hub note with 900 backlinks pages and adds up; front matter alone and a line alone refused; bad `links_from` |
| 4 `manuscript` no chapter | `chapter` optional: the first visible chapter in path order. Every reply carries `next_chapter`. `query-chapter-required` removed. Ambiguity still refuses | first chapter and `next_chapter`; ambiguous name refused |
| 5 `search` long line | hit comes back `{path, line, excerpt: null, excerpt_too_long: true, scope}`, other hits still return | marked hit and unmarked hits in one reply |
| 6 `exclude` | path-prefix reading only. The test names `EXCLUSION` and `EXCLUDED_PATHS` in `query-fixture.mjs`, so C2-15 (row 267) swaps two constants | exclusion hides from the hybrid and not from the source |
| 8 hybrid `get` | unchanged code; test added | hybrid gets a source's playground file as `null`; the source gets it with `scope: "playground"` |
| PRD 1.14 (row 268) | `universeInfo` reports `kind` from the mapping (`draft` included) | four tests below |
PRD 1.14 tests, one each: a lookup naming a `book` leaves `book: null` files out (find and search);
`library_manuscript` never reads a `book: null` file; a draft universe has no canon, so its lookups return
files only with `scope: "playground"`; `library_universe` on a draft returns kind, name and prefixes.
The levels fixture is `WITH_LEVELS` in `query.test.mjs`: harbor gets `Notes/Harbor/` as a universe-level
playground prefix, and `idea` is a draft over `Drafts/New World/`.
`query-fixture.mjs` builds under `tmpdir()`, as `helpers.mjs` does (ruling "tmpdir flag"). The earlier
flag about `helpers.mjs` building under `/tmp` is closed by that ruling: the gate sets `TMPDIR`.
Refusal codes now: `query-argument-invalid`, `query-universe-unknown`, `query-chapter-ambiguous`,
`query-item-too-large`. All four approved in the rulings.
### Grep results (grep only, not a test run)
- `git grep` over `packages/*/tests/*` and `scripts/*` for `query.mjs` and `index-build`: no hit outside
`packages/library/tests/query*.test.mjs` and `index-build.test.mjs`.
- 64-hex pins naming a library path: none. Positive control: the same pathspec finds the sha256 lines in
`packages/cli/tests/case1-hostcheck.test.mjs` and the `packages/library/src/index.mjs` existence check at
`packages/cli/tests/stack-copy.test.mjs:52`.
- Tests that read `packages/library/src` as a directory: `packages/cli/tests/stack-release-fixture.mjs:309`
copies `packages/library/src` whole into a release fixture. It carries no file list, so two new files break
no assertion. `stack-release.test.mjs` is not in the gate unless Sage wants it.
- Tests that import the library index as a namespace: `packages/bus/tests/library.test.mjs` and
`library-sync.test.mjs`. Neither enumerates exports, so the two export lines add no break. Both run
whole in phase B.
### Run limits (fixed before any run)
Every node file runs as its own `setsid -w timeout -k 10 LIMIT node --test FILE`; nothing runs unbounded.
- Node test files: LIMIT 900 s. The library test files have no recorded time. 900 s is above the longest
recorded whole-package run I know of (cli, 591 s, row 281 BUILD.md "Runs" step 3), so it holds the 3x rule
for any single file. The first run records each file's time, and a file past 300 s gets a limit re-derived
from it before the next run. `query.test.mjs` builds a 900-file repo and a 3,000-file index rebuild is in
`index-build.test.mjs`; both are the files most likely to need it.
- No shell script is in this row's gate, so no script limit applies.
Any timeout goes to Sage, then Mos, unplaced (mos-1275).
### Phase B (not run; Sage's CLEAR first, then a rebase onto 253's tip)
```
export TMPDIR=/mnt/storage/scratch/tmp
cd <worktree>/packages/library
for f in tests/*.test.mjs; do setsid -w timeout -k 10 900 node --test "$f"; done
cd ../bus
for f in tests/library.test.mjs tests/library-sync.test.mjs; do setsid -w timeout -k 10 900 node --test "$f"; done
cd ../cli
setsid -w timeout -k 10 900 node --test tests/stack-copy.test.mjs
```
Mutants I propose (my list, not read off the brief; each is meant to be killed by the named test, and none
has been thrown; the kill claims are unverified until CLEAR):
- M-a: `sees()` returns true for a file of another universe: killed by "find is bound to its universe".
- M-b: the playground filter dropped: killed by "playground appears only when asked for".
- M-c: the hybrid reads a source's playground: killed by "a hybrid gets a source playground file as null".
- M-d: `links_next` always null: killed by the hub paging test.
- M-e: the book filter lets `book: null` through: killed by the PRD 1.14 book test.
### Status
Built, not run. `node --check` has not been run on this round's files (no node before the CLEAR); the
phase A pass above covered the earlier text only. The commit is local and waits for no gate under Sage's
sage-2716 instruction, which allows git and docs work only; push and claim wait for Sage's go.
-2
View File
@@ -1593,5 +1593,3 @@ are never rewritten or removed; corrections are new entries.
2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs).
2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247
2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z.
2026-10-23 | Beakley (88b97f65, Codex gpt-6.1-sol) | Row 217 #1718 takeover under Q245 | Registered beakley/row217; read brief/design and standing Sage rulings; awaiting Launchpad ref before implementation. Queue next refused linked worktree; reported to Sage. Untested, no Node/test run pending CLEAR.
+49 -135
View File
@@ -96,7 +96,16 @@ export const TASK_PAGE_MAX = 200;
// request allowed past the ordinary 64 KiB envelope (server.mjs and client.mjs), up to its content cap.
export const PROJECT_FILE_VERBS = Object.freeze(['project.files', 'project.file', 'project.file.save']);
// C2-03a: library.sync is the human's fetch, and with `accept` the one way past library-diverged.
export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync']);
// Row 224: pure agent lookups are separate from the three human-only verbs. Listing them
// here also makes server.mjs await their host effects instead of serializing a Promise as {}.
const LIBRARY_LOOKUPS = Object.freeze({
'library.find': { method: 'find', fields: ['universe', 'query', 'type', 'book', 'scope', 'offset'], required: ['universe', 'query'] },
'library.get': { method: 'get', fields: ['universe', 'path', 'scope', 'from', 'links_from'], required: ['universe', 'path'] },
'library.search': { method: 'search', fields: ['universe', 'text', 'book', 'scope', 'offset'], required: ['universe', 'text'] },
'library.manuscript': { method: 'manuscript', fields: ['universe', 'book', 'scope', 'chapter', 'from', 'to'], required: ['universe', 'book'] },
'library.universe': { method: 'universeInfo', fields: ['universe'], required: ['universe'] },
});
export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync', ...Object.keys(LIBRARY_LOOKUPS)]);
export const PROJECT_SAVE_VERB = 'project.file.save';
export const PROJECT_SAVE_REQUEST_MAX = 1024 * 1024 + 64 * 1024;
export const WEB_VERBS =Object.freeze(['web.search', 'web.fetch']);
@@ -203,26 +212,15 @@ const canonical = (x) =>
Number.isFinite(Date.parse(x)) &&
new Date(x).toISOString() === x;
export class BusError extends Error {
// C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on.
constructor(code, reason) {
constructor(code) {
super(code);
this.code = code;
if (reason !== undefined) this.reason = reason;
}
}
const fail = (code) => {
throw new BusError(code);
};
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
// C2-06 (design 5.2, B7): the page store keeps a session's last 20 broker fetches, 200 KB of text each.
const PAGE_COUNT = 20;
const PAGE_BYTES = 200 * 1024;
// C2-06: the guard's check event takes an id derived from the message's, so a thread read finds it
// through the unique index on events.id (there is none on subject).
const checkEventId = (mid) => {
const h = createHash('sha256').update('message.checked:' + mid).digest('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
};
const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0');
const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
function keys(x, allowed, required = []) {
@@ -400,11 +398,6 @@ export class Broker {
#credentialVerbs;
#project;
#library;
// C2-06 (design 5.2, B7): cap -> the session's kept web.fetch pages, in a library business only.
// In memory, dropped with the session.
#pages = new Map();
// C2-06: { s, check } while a guarded message.send or a thread.messages re-enters request().
#guardPass = null;
#usage;
#limits;
#harnessOn;
@@ -1419,10 +1412,7 @@ export class Broker {
this.#launcherReleased.delete(key);
this.#accessRevoked.delete(key);
for (const [cap, s] of this.#sessions)
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) {
this.#sessions.delete(cap);
this.#pages.delete(cap);
}
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
return this.#store.transaction(() => {
const h = this.#own(session);
const released = h?.op === 'claim' && h.holder_run === session.run;
@@ -1504,7 +1494,6 @@ export class Broker {
}
disconnect(cap) {
this.#sessions.delete(cap);
this.#pages.delete(cap);
}
identity(cap) {
const s = this.#session(cap);
@@ -4686,19 +4675,10 @@ export class Broker {
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (LIBRARY_VERBS.includes(request.verb))
return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) });
// C2-06 (design 5.2): an agent's message to the human in a library business passes the citation
// guard first, outside any transaction, and thread.messages adds each path cite's `last`. Both
// then re-enter request() for the synchronous path below.
if (this.#guardPass?.s !== s) {
if (request.verb === 'message.send' && this.#guarded(s, args.to))
return this.#guardedSend(cap, s, request, structuredClone(args));
if (request.verb === 'thread.messages' && this.#libraryProject(s.business) && this.#library?.last)
return this.#withLast(cap, s, request);
}
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -4744,12 +4724,6 @@ export class Broker {
keys(args, VETO_KEYS, VETO_KEYS);
this.#human(s);
this.#mayHuman(s, "veto.set", args.name);
}
// C2-06 M11: the human's replyLimits verb. The tester is refused by the gate above and an agent
// session by human-required; the writer's fence refuses any other actor.
else if (request.verb === "library.limits.set") {
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
this.#human(s);
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
@@ -5160,10 +5134,42 @@ export class Broker {
throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request);
}
}
// Row 224 C2-05b: the agent never supplies a host repo, commit or business. Its current
// holder and business entry are rechecked before/after awaits; runtime owns Git and queries.
async #requestLibraryLookup(s, { verb, args }) {
try {
this.#agent(s);
const business = this.#business(s.business);
const projects = Object.entries(business.projects ?? {}).filter(([, entry]) => entry.kind === 'library');
if (!projects.length || typeof this.#library?.lookup !== 'function') fail('library-not-configured');
const spec = LIBRARY_LOOKUPS[verb];
keys(args, ['project', ...spec.fields], spec.required);
if (!text(args.universe, 160)) fail('invalid-request');
if (args.project === undefined && projects.length !== 1) fail('invalid-request');
const project = args.project ?? projects[0][0];
if (!text(project, 160) || !projects.some(([name]) => name === project)) fail('library-not-configured');
const revision = JSON.stringify(business.projects[project]);
const authorize = () => {
this.#agent(s);
this.#processingGate(s, verb);
if (JSON.stringify(this.#business(s.business).projects?.[project]) !== revision) fail('library-not-configured');
};
const { project: ignored, ...query } = args;
authorize();
const result = await this.#library.lookup({ business, project }, { method: spec.method, args: query, authorize });
authorize();
this.#secretCheck(result);
return result;
} catch (error) {
const Failure = this.#library?.LibraryError;
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, { verb, args });
}
}
// C2-04 M4: only a verified human reads library text or status. Host Git
// functions stay injected, preserving this module's sandbox import closure.
async #requestLibrary(s, request) {
const { verb, args } = request;
if (Object.hasOwn(LIBRARY_LOOKUPS, verb)) return this.#requestLibraryLookup(s, request);
try {
this.#human(s);
const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept'] : ['project'];
@@ -5173,14 +5179,7 @@ export class Broker {
const context = { business: this.#business(s.business), project: args.project };
let result;
if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
else if (verb === 'library.status') {
result = await this.#library.status(context);
// C2-06: the reply limits in force, read from the file now, and the keys that fell back.
if (this.#library.replyLimits) {
const { requireCitation, citationsPerReply, fallback, unreadable } = this.#library.replyLimits(s.business);
result = { ...result, replyLimits: { requireCitation, citationsPerReply, fallback, problem: unreadable ? 'reply-limits-unreadable' : null } };
}
}
else if (verb === 'library.status') result = await this.#library.status(context);
else {
// A fetch that did not move served still answers with the code, so the refusal is recorded.
const { state, code } = await this.#library.sync(context, { accept: args.accept ?? null });
@@ -5194,78 +5193,6 @@ export class Broker {
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request);
}
}
// C2-06: the business's library project, the first in file order, or null.
#libraryProject(business) {
return Object.entries(this.#business(business).projects ?? {}).find(([, p]) => p.kind === 'library')?.[0] ?? null;
}
// C2-06 (design 5.2): every agent session's message to the human in a business with a library project
// is guarded, whatever the sender's definition.
#guarded(s, to) {
return to === 'human' && !s.human && !s.tester && !s.user && !s.reader && !s.console && this.#libraryProject(s.business) !== null;
}
#reenter(cap, s, request, check) {
this.#guardPass = { s, check };
try {
return this.request(cap, request);
} finally {
this.#guardPass = null;
}
}
// As #requestProject: the git reads run outside any SQL transaction. A guard refusal is recorded with
// its code and reaches the caller with the guard's reason; the send then runs, and stores the check
// result in its own transaction.
async #guardedSend(cap, s, request, args) {
let check;
try {
this.#store.transaction(() => this.#agent(s));
if (!this.#library?.guard || !this.#library?.replyLimits) fail('project-not-configured');
if (typeof args.body !== 'string') fail('invalid-request');
const business = this.#business(s.business);
check = await this.#library.guard({ business, project: this.#libraryProject(s.business) }, args.body, {
limits: this.#library.replyLimits(s.business),
definition: business.roles[s.role]?.definition ?? null,
pages: this.#pages.get(cap) ?? [],
});
} catch (e) {
const Guard = this.#library?.GuardError;
throw this.#refused(s, Guard && e instanceof Guard ? new BusError(e.code, e.reason) : e, request);
}
return this.#reenter(cap, s, { verb: 'message.send', args }, check);
}
// C2-06 (sage-3595, morgana-0200): each path cite of a checked row gains `last`, the file's last commit
// at the served tip, one git call per distinct path. Rows without `check` are unchanged.
async #withLast(cap, s, request) {
const rows = this.#reenter(cap, s, request, null);
if (!rows.some((m) => m.check)) return rows;
const cites = (m) => {
try {
const c = JSON.parse(m.body).citations;
return Array.isArray(c) ? c : [];
} catch {
return [];
}
};
let last = new Map();
try {
const paths = rows.filter((m) => m.check).flatMap((m) => cites(m).filter((c) => typeof c?.path === 'string').map((c) => c.path));
last = await this.#library.last({ business: this.#business(s.business), project: this.#libraryProject(s.business) }, paths);
} catch {
last = new Map();
}
return rows.map((m) => {
if (!m.check) return m;
const body = cites(m);
return { ...m, check: { ...m.check, citations: m.check.citations.map((c, i) =>
c.scope === 'web' ? c : { ...c, last: last.get(body[i]?.path) ?? null }) } };
});
}
// C2-06 (B7): a fetch's text, kept for url cites in a library business only.
#keepPage(cap, s, url, page) {
if (!this.#sessions.has(cap) || !this.#libraryProject(s.business) || typeof page?.text !== 'string') return;
const text = Buffer.from(page.text).subarray(0, PAGE_BYTES).toString();
const kept = { url, finalUrl: typeof page.finalUrl === 'string' ? page.finalUrl : null, text };
this.#pages.set(cap, [...(this.#pages.get(cap) ?? []), kept].slice(-PAGE_COUNT));
}
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
// cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request().
async requestTask(cap, request, handler) {
@@ -5349,7 +5276,6 @@ export class Broker {
throw refuse(new BusError('web-failed'));
}
complete(out.summary);
if (verb === 'web.fetch') this.#keepPage(cap, s, args.url, out.result);
return { ...out.result, untrusted: true, notice: WEB_UNTRUSTED, source: verb };
}
// Row 210 (#1707, design D5): host.run, one sudo command on a registered host over ssh, for a
@@ -5476,7 +5402,7 @@ export class Broker {
by,
);
this.#routeSession(s.session);
return { role: s.role, run: s.run, thread: s.thread, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
return { role: s.role, run: s.run, thread: s.thread };
}
if (
this.#store.get(
@@ -5500,8 +5426,7 @@ export class Broker {
by,
);
this.#routeWaiting(s.business, s.role);
// C2-06 (C2-07): the runner's switch to the library texts and retry. Absent elsewhere.
return { role: s.role, run: s.run, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
return { role: s.role, run: s.run };
}
case 'role.release': {
keys(a, []);
@@ -5865,10 +5790,6 @@ export class Broker {
if (this.#userThread(s) && to === 'human') fail('user-thread-human');
this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to);
string(a.body, 32768);
// C2-06: a guarded send is stored only through #guardedSend's pass, with its check result.
const guarded = this.#guarded(s, to);
if (guarded && this.#guardPass?.s !== s) fail('storage-refused');
const check = guarded ? this.#guardPass.check : null;
if (
a.class !== undefined &&
![
@@ -5915,9 +5836,6 @@ export class Broker {
a.corrects ?? null,
a.body,
);
// C2-06: the stored check result shares the message's transaction (sage-3595 item 3). No new
// event kind, which would be a schema step: the events kind CHECK is migrations/3b.sql:9.
if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));
const cancelled = this.#route(s.business, mid, to, thread);
const request = s.human
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
@@ -6537,15 +6455,11 @@ export class Broker {
)?.seq;
if (after === undefined) fail('message-not-found');
}
// C2-06 (row 225, morgana-0198): a row the citation guard checked carries the stored result.
return this.#store.all(
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
session,
after,
).map((m) => {
const e = this.#store.get("SELECT body FROM events WHERE id=? AND kind='action.allowed' AND subject=?", checkEventId(m.id), m.id);
return e ? { ...m, check: JSON.parse(e.body).check } : m;
});
);
}
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
+1 -2
View File
@@ -41,8 +41,7 @@ export class Client {
const r = JSON.parse(input.toString('utf8'));
if (r.ok === true) end(null, r.result);
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
end(new BusError(r.error));
else end(new BusError('invalid-response'));
} catch {
end(new BusError('invalid-response'));
+35 -10
View File
@@ -14,7 +14,7 @@ import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { organizationWriter, OrganizationError } from '../../business/src/organization.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
@@ -87,6 +87,38 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
}
return (name) => levels.get(name) ?? null;
}
// Row 224 C2-05b: a host-only adapter. A failed fetch keeps serving immutable old
// objects, as C2-03a requires; no caller may select a repo, commit or sync --accept.
export function createLibraryLookup({ dataRoot, sync, now = () => Date.now() }) {
const methods = { find: libraryModule.find, get: libraryModule.get, search: libraryModule.search,
manuscript: libraryModule.manuscript, universeInfo: libraryModule.universeInfo };
return async (context, { method, args, authorize }) => {
if (!Object.hasOwn(methods, method)) throw new libraryModule.LibraryError('query-argument-invalid', 'unknown lookup');
authorize();
const source = { ...context, dataRoot };
let state = await libraryModule.libraryStatus(source);
authorize();
// sage-2819 / dewey-0852: failed attempts also pace inline retries. Otherwise an
// outage makes every lookup refetch while lastFetch remains old or null.
const recentAttempt = Math.max(state.lastFetch === null ? -Infinity : Date.parse(state.lastFetch),
state.lastError === null ? -Infinity : Date.parse(state.lastError.at));
const age = now() - recentAttempt;
if (age > libraryModule.SYNC_INTERVAL_MS || age < 0) {
try { await sync(source); }
catch (error) {
// A timer may already be fetching this mirror. Its current served state is still safe.
if (!(error instanceof libraryModule.LibraryError) || error.code !== 'library-sync-busy') throw error;
}
authorize();
state = await libraryModule.libraryStatus(source);
}
if (!state.served) throw new libraryModule.LibraryError('library-commit', 'library has never been synced');
authorize();
return methods[method]({ repo: libraryModule.mirrorPath(source), commit: state.served,
mapping: libraryModule.checkMapping(context.business.projects[context.project]) }, args);
};
}
export async function startBroker({
dataRoot,
businesses,
@@ -301,17 +333,12 @@ export async function startBroker({
store,
businesses,
library: {
lookup: createLibraryLookup({ dataRoot, sync: context =>
processing.work(() => libraryModule.sync(context, syncOptions(context.business))) }),
readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }),
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
LibraryError: libraryModule.LibraryError,
// C2-06: the citation guard, the reply limits read from the business file at each check (none
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
replyLimits: id => libraryModule.resolveReplyLimits(organization
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
GuardError: libraryModule.GuardError,
},
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
@@ -341,8 +368,6 @@ export async function startBroker({
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
+5 -23
View File
@@ -7,11 +7,6 @@ const LIMIT = 65536;
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
// only the citation guard's refusals do, so every other envelope is byte-identical to before.
const refusal = (e, other) => e instanceof BusError
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
: { ok: false, error: other };
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
@@ -20,7 +15,6 @@ const refusal = (e, other) => e instanceof BusError
// host-not-configured.
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
// (row 252) proof.
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
try {
lstatSync(path);
@@ -102,7 +96,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestTask(cap, { verb: r.verb, args: r.args ?? {} }, tasks.handle)
.then(
(result) => finish({ ok: true, result }),
(e) => finish(refusal(e, 'adapter-failed')),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }),
)
.finally(() => {
if (own) release();
@@ -120,7 +114,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestWeb(cap, { verb: r.verb, args: r.args ?? {} }, service, { onAdmit: () => socket.setTimeout(service.timeout) })
.then(
(result) => finish({ ok: true, result }),
(e) => finish(refusal(e, 'web-failed')),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }),
)
.finally(() => {
if (own) release();
@@ -136,7 +130,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestHost(cap, { verb: r.verb, args: r.args ?? {} }, host, { onAdmit: (ms) => socket.setTimeout(ms + 10000) })
.then(
(result) => finish({ ok: true, result }),
(e) => finish(refusal(e, 'host-failed')),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }),
)
.finally(() => {
if (own) release();
@@ -159,26 +153,14 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
socket.setTimeout(120_000);
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
result => { if (!socket.destroyed) finish({ ok: true, result }); },
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
).finally(() => { if (own) release(); });
return;
}
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
// C2-06: a guarded message.send and a library business's thread.messages read Git first, so
// the broker answers them with a promise. Every other verb stays synchronous.
if (result instanceof Promise) {
const own = transient;
transient = null;
socket.setTimeout(120_000);
result.then(
value => { if (!socket.destroyed) finish({ ok: true, result: value }); },
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
).finally(() => { if (own) release(); });
return;
}
finish({ ok: true, result });
} catch (e) {
finish(refusal(e, 'invalid-envelope'));
finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' });
} finally {
if (transient) release();
}
@@ -1,56 +0,0 @@
// E4/sage-3668: real flock contention must not turn a valid cite into an invalid-cite refusal.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ensureMirror, mirrorPath, SYNC_STATE_FILE, withLibraryLock } from '../../library/src/mirror.mjs';
import { guardReply } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
import { citationWorld, citedBody, fixtureCite } from './citation-helpers.mjs';
test('C2-06 held library lock gives the agent citation-unavailable and its retry reason', { timeout: 25000 }, async t => {
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const context = { business: f.business, project: 'books', dataRoot };
await ensureMirror(context); const mirror = mirrorPath(context);
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
writeFileSync(join(mirror, SYNC_STATE_FILE), JSON.stringify({ version: 1, branch: 'main', served: f.commit,
lastFetch: null, lastError: null, diverged: null, problems: [] }), { mode: 0o600 });
f.adapter.guard = (ctx, body, opts) => guardReply({ ...ctx, dataRoot }, body, opts);
f.call(f.agent, 'role.claim');
const socket = join(f.root, 'availability.sock'), server = await serve({ broker: f.broker, path: socket });
t.after(() => server.close()); const client = new Client({ path: socket, cap: f.agent, timeout: 15000 });
const args = { to: 'human', body: citedBody([fixtureCite(f.commit)]) };
await client.call('message.send', args); // Establish that the cite and real mirror are valid.
const before = f.store.get('SELECT count(*) n FROM messages').n;
await withLibraryLock(mirror, async () => {
await assert.rejects(client.call('message.send', args), e => {
assert.equal(e.code, 'citation-unavailable');
assert.equal(e.reason, 'The library is busy or unavailable. Retry the answer when the library is available.');
return true;
});
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before);
// A fixed fallback carries no library cite and must not need the contested lock.
await client.call('message.send', { to: 'human', body: '(no answer passed the citation check)' });
});
await client.call('message.send', args); // No lock mutation or lost valid cite after release.
});
test('C2-06 malformed sync state is availability failure, never a fabricated invalid cite', async t => {
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const context = { business: f.business, project: 'books', dataRoot };
await ensureMirror(context); const mirror = mirrorPath(context);
writeFileSync(join(mirror, SYNC_STATE_FILE), '{broken', { mode: 0o600 });
await assert.rejects(guardReply(context, citedBody([fixtureCite(f.commit)]), {
limits: f.limits(), definition: 'coder', pages: [],
}), { code: 'citation-unavailable' });
});
test('C2-06 no mirror or freshly initialized empty mirror still means no served citation', async t => {
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const context = { business: f.business, project: 'books', dataRoot };
const check = () => guardReply(context, citedBody([fixtureCite(f.commit)]), { limits: f.limits(), definition: 'coder', pages: [] });
await assert.rejects(check(), { code: 'citation-invalid' });
await ensureMirror(context); await assert.rejects(check(), { code: 'citation-invalid' });
});
-153
View File
@@ -1,153 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
import { organizationWriter, setReplyLimits } from '../../business/src/organization.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { citationWorld, citedBody, fixtureCite, guardCases } from './citation-helpers.mjs';
for (const definition of ['coder', 'research']) test(`C2-06 ${definition} send_message guard covers every table refusal; inserts nothing`, async t => {
const f = citationWorld(t, { definition }); f.call(f.agent, 'role.claim');
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: true } }));
for (const [code, body] of guardCases(f.commit)) {
const count = f.store.get('SELECT count(*) n FROM messages').n;
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body }), e => e.code === code && e.reason.length <= 300);
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, count, code);
assert.equal(f.store.all("SELECT * FROM events WHERE json_extract(body,'$.action')='message.checked'").length, 0);
}
const sent = await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) });
const row = f.store.get("SELECT body,subject FROM events WHERE json_extract(body,'$.action')='message.checked'");
assert.equal(row.subject, sent.id);
assert.deepEqual(JSON.parse(row.body).check, { citations: [{ scope: 'canon', agent: null, file: f.commit }] });
});
test('C2-06 stored check carries the file author trailer at an unrelated cited tip', async t => {
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
const path = 'Book One/Characters/Mara Vell.md';
writeFileSync(join(f.repo, path), readFileSync(join(f.repo, path), 'utf8') + 'A fixture note.\n');
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'Fixture note\n\nMosaic-Agent: acme/scribe');
const file = git(f.repo, 'rev-parse', 'HEAD');
writeFileSync(join(f.repo, 'Inbox Note.md'), 'Unrelated fixture.\n'); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'unrelated');
const cited = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(cited);
assert.notEqual(cited, file);
const sent = await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(cited)]) });
const event = f.store.get("SELECT body FROM events WHERE subject=? AND json_extract(body,'$.action')='message.checked'", sent.id);
assert.deepEqual(JSON.parse(event.body).check, { citations: [{ scope: 'canon', agent: 'acme/scribe', file }] });
const [row] = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
assert.deepEqual(row.check.citations, [{ scope: 'canon', agent: 'acme/scribe', file, last: file }]);
});
test('C2-06 check evidence and insert are atomic when storing evidence fails', async t => {
const f = citationWorld(t); f.call(f.agent, 'role.claim');
f.store.run(`CREATE TRIGGER fixture_check_refused BEFORE INSERT ON events
WHEN json_extract(NEW.body,'$.action')='message.checked' BEGIN SELECT RAISE(ABORT,'fixture check failure'); END`);
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) }));
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, 0);
});
test('C2-06 live limits change both directions; unreadable file keeps checks and exact fixed texts', async t => {
const f = citationWorld(t); f.call(f.agent, 'role.claim');
const send = body => f.call(f.agent, 'message.send', { to: 'human', body });
await send('free');
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: true } }));
await assert.rejects(send('free'), { code: 'citation-missing' });
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: false } })); await send('free');
for (const invalid of ['{broken', 'null', '[]']) {
writeFileSync(f.settings, invalid);
const before = f.store.get('SELECT count(*) n FROM messages').n;
await assert.rejects(send('free'), { code: 'citation-missing' });
await assert.rejects(send(citedBody(Array(21).fill(fixtureCite(f.commit)))), { code: 'citations-over-limit' });
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before);
for (const text of Object.values(FIXED_TEXTS)) await send(text);
assert.equal((await f.call(f.human, 'library.status', { project: 'books' })).replyLimits.problem, 'reply-limits-unreadable');
}
rmSync(f.settings);
await assert.rejects(send('free'), { code: 'citation-missing' });
});
test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at disconnect', async t => {
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
const fetch = async (n, text = `page ${n}`) => f.broker.requestWeb(f.agent, { verb: 'web.fetch', args: { url: `https://fixture.invalid/${n}` } },
{ handle: async () => ({ result: { text }, summary: { outcome: 'ok' } }) });
const send = (n, quote) => f.call(f.agent, 'message.send', { to: 'human', body: citedBody([{ url: `https://fixture.invalid/${n}`, quote }]) });
await fetch(0); await send(0, 'page 0');
for (let n = 1; n <= 20; n++) await fetch(n);
await assert.rejects(send(0, 'page 0'), { code: 'citation-unfetched' }); await send(1, 'page 1');
await fetch(21, 'x'.repeat(200 * 1024) + 'AFTER_CAP');
await send(21, 'xxxx'); await assert.rejects(send(21, 'AFTER_CAP'), { code: 'citation-unfetched' });
f.broker.disconnect(f.agent);
f.broker.endLaunch({ business: 'acme', run: 'coder-run', reason: 'fixture', exitCode: 0 });
const replacement = f.broker.bindLaunch({ ...f.launch, run: 'next-run' });
f.call(replacement, 'role.claim');
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
});
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
const client = new Client({ path, cap: f.agent });
await assert.rejects(client.call('message.send', { to: 'human', body: 'fixture' }), e => e.code === 'citation-invalid' && e.reason === ('guard reason '.repeat(50)).slice(0, 300));
const raw = await new Promise((resolve, reject) => {
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
});
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
});
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
const f = citationWorld(t, { library: false });
assert.deepEqual(f.call(f.agent, 'role.claim'), { role: 'coder', run: 'coder-run' });
assert.ok(f.call(f.agent, 'message.send', { to: 'human', body: 'free' }).id);
});
test('C2-06 thread messages carry file/last evidence with one read per distinct path', async t => {
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
const path = 'Book One/Characters/Mara Vell.md';
await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit), fixtureCite(f.commit)]) });
await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) });
const rows = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
assert.equal(rows.length, 2); assert.deepEqual(f.reads, [path]);
for (const row of rows) for (const c of row.check.citations) {
assert.equal(c.file, f.commit); assert.equal(c.last, f.commit); assert.equal(c.agent, null);
}
writeFileSync(join(f.repo, path), 'A changed fixture.\n'); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'changed fixture');
const changed = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(changed); f.reads.length = 0;
const newer = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
assert.deepEqual(f.reads, [path]); assert.equal(newer[0].check.citations[0].file, f.commit); assert.equal(newer[0].check.citations[0].last, changed);
});
test('C2-06 guarded request snapshot cannot change between check and insert', async t => {
let finish;
const f = citationWorld(t, { guard: () => new Promise(r => { finish = r; }) }); f.call(f.agent, 'role.claim');
const request = { verb: 'message.send', args: { to: 'human', body: 'checked fixture' } };
const sent = f.broker.request(f.agent, request); request.args.body = 'unchecked replacement';
finish(null); await sent;
assert.equal(f.store.get("SELECT body FROM messages WHERE from_role='coder'").body, 'checked fixture');
});
test('C2-06 library.limits.set is human-only and invalid settings leave file and receipts unchanged', async t => {
let writer;
const f = citationWorld(t, { organization: (id, verb, args, actor) => {
assert.equal(verb, 'library.limits.set'); return setReplyLimits(writer, id, args, actor);
} });
const dir = join(f.root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), businessDoc(f.root));
writer = organizationWriter({ dir, rolesDir: rolesCopy(f.root) });
f.adapter.replyLimits = () => resolveReplyLimits(writer.readRaw('acme'));
f.call(f.agent, 'role.claim');
await f.call(f.human, 'library.limits.set', { revision: writer.read('acme').revision, replyLimits: { requireCitation: true } });
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body: 'free' }), { code: 'citation-missing' });
const before = readFileSync(file), log = readFileSync(writer.logFile('acme'));
const args = { revision: writer.read('acme').revision, replyLimits: { citationsPerReply: 0 } };
for (const [cap, code] of [[f.human, 'invalid-request'], [f.agent, 'human-required'], [f.tester, 'tester-refused']]) {
assert.throws(() => f.call(cap, 'library.limits.set', args), { code });
assert.deepEqual(readFileSync(file), before); assert.deepEqual(readFileSync(writer.logFile('acme')), log);
}
assert.throws(() => f.call(f.human, 'library.limits.set', { ...args, replyLimits: { invented: true } }), { code: 'invalid-request' });
});
-65
View File
@@ -1,65 +0,0 @@
// C2-06: real broker, real Git reads and fixture-only library. Reused by runner acceptance tests.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { checkReply, GuardError, libraryReader } from '../../library/src/guard.mjs';
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { resolveReplyLimits } from '../../library/src/limits.mjs';
import { mapping, repo, MARKER } from '../../library/tests/helpers.mjs';
export const citedBody = citations => JSON.stringify({ text: 'A fixture answer.', citations });
export const fixtureCite = (commit, extra = {}) => ({ path: 'Book One/Characters/Mara Vell.md', commit, lines: [9, 9], quote: 'Grey eyes.', ...extra });
export function citationWorld(t, { definition = 'coder', library = true, guard: overrideGuard, organization = null, sendAuthority = true } = {}) {
const r = repo(t), root = r.root, state = join(root, 'bus'); mkdirSync(state, { mode: 0o700 });
const store = new Store(state); t.after(() => store.close());
const map = mapping(), settings = join(root, 'business.json');
writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } }));
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
...(library ? { projects: { books: map } } : {}),
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
const limits = () => {
try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); }
catch { return resolveReplyLimits({ readable: false }); }
};
let served = r.commit;
const reads = [];
const adapter = {
GuardError,
replyLimits: limits,
guard: overrideGuard ?? ((context, body, opts) => checkReply(body, {
limits: opts.limits, mapping: map, library: libraryReader(r.repo, served, map),
web: { allowed: ['companion', 'research'].includes(opts.definition), pages: opts.pages },
})),
last: async (context, paths) => {
const out = new Map(); for (const path of new Set(paths)) { reads.push(path); out.set(path, await libraryReader(r.repo, served, map).last(path)); } return out;
},
readFile: async () => ({}), status: async () => ({ project: 'books', served }),
};
const broker = new Broker({ store, businesses: { acme: business }, library: adapter, organization });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const project = THREAD_DEFINITIONS.includes(definition)
? broker.request(human, { verb: 'project.create', args: { name: 'Citation fixture' } }) : null;
const thread = project
? broker.request(human, { verb: 'thread.create', args: { project: project.id, name: 'Fixture thread', instance: 'coder' } }) : null;
const launch = { business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi', ...(thread ? { thread: thread.thread ?? thread.id } : {}) };
const agent = broker.bindLaunch(launch);
const tester = broker.bindTester({ business: 'acme' });
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
return { ...r, root, store, business, broker, agent, human, tester, settings, limits, call, adapter, reads, launch, thread, setServed: commit => { served = commit; } };
}
export function guardCases(commit) {
const good = fixtureCite(commit);
return [
['reply-invalid', JSON.stringify({ text: 'x', citations: [], invented: true })],
['citation-missing', 'Uncited fixture answer.'],
['citations-over-limit', citedBody(Array(21).fill(good))],
['citation-invalid', citedBody([{ ...good, quote: 'wrong fixture quote' }])],
['playground-cited-as-canon', citedBody([{ ...good, path: 'Book One/Playground/What If.md', lines: [4, 4], quote: MARKER, as: 'canon' }])],
['citation-cross-universe', citedBody([good, { ...good, path: 'Second World/Chapters/01.md', lines: [1, 1], quote: MARKER }])],
['citation-unfetched', citedBody([{ url: 'https://fixture.invalid/page', quote: 'no fetch' }])],
];
}
@@ -1,29 +0,0 @@
// E7: exercise startBroker's real live readRaw adapter, rather than replacing the library adapter.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { validateBusinessDocument } from '../../business/src/business.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs';
test('C2-06 runtime reads changed and unreadable reply limits without restarting', async t => {
const root = mkdtempSync(join(tmpdir(), 'citation-runtime-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root), rolesDir = rolesCopy(root), dir = join(root, 'config'), dataRoot = join(root, 'data');
doc.projects.books = mappingEntry(); doc.replyLimits = { requireCitation: false, citationsPerReply: 3 };
const file = writeJson(join(dir, 'businesses/acme.json'), doc);
const business = validateBusinessDocument(doc, file, { rolesDir }); mkdirSync(dataRoot, { mode: 0o700 });
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir } });
t.after(() => runtime.close());
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const status = () => runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
assert.deepEqual((await status()).replyLimits, { requireCitation: false, citationsPerReply: 3, fallback: [], problem: null });
doc.replyLimits = { requireCitation: true, citationsPerReply: 7 }; writeJson(file, doc);
assert.deepEqual((await status()).replyLimits, { requireCitation: true, citationsPerReply: 7, fallback: [], problem: null });
writeFileSync(file, '{broken');
assert.deepEqual((await status()).replyLimits, { requireCitation: true, citationsPerReply: 20, fallback: [], problem: 'reply-limits-unreadable' });
rmSync(file);
assert.equal((await status()).replyLimits.problem, 'reply-limits-unreadable');
});
+251
View File
@@ -0,0 +1,251 @@
// Row 224: exercise broker authorization, real C2-05a queries and host served snapshots.
// All bodies and remotes are made up. External sync is replaced only at its trusted host seam.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { createLibraryLookup, startBroker } from '../src/runtime.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import * as library from '../../library/src/index.mjs';
import { scratchRepo, note } from '../../library/tests/query-fixture.mjs';
import { mappingEntry, git } from '../../library/tests/helpers.mjs';
const NOW = Date.parse('2026-10-22T16:00:00.000Z');
const VERBS = ['library.find', 'library.get', 'library.search', 'library.manuscript', 'library.universe'];
const GET = { universe: 'harbor', path: 'Book One/Characters/Tomas.md' };
const ARGS = [
{ universe: 'harbor', query: 'Tomas' }, GET,
{ universe: 'harbor', text: 'ferry' }, { universe: 'harbor', book: 'Book One' }, { universe: 'harbor' },
];
const TEXTS = {
'Book One/Characters/Tomas.md': note('character', 'Tomas', 'He runs the ferry. [[Elsewhere]]'),
'Book One/Chapters/01.md': '# A ferry\nOne line.\n',
'Book One/Playground/What If.md': note('character', 'Maybe Tomas', 'Unwritten ferry.'),
'Second World/Characters/Elsewhere.md': note('character', 'Elsewhere', 'Other ferry.'),
};
async function world(t, { projects, adapter, claimed = true, now = () => NOW, files = TEXTS } = {}) {
const f = scratchRepo(t, files), dataRoot = join(f.root, 'data');
mkdirSync(dataRoot);
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
const context = { business, project: 'books', dataRoot };
let mirror = null, state = null, fetches = 0;
if (business.projects.books?.kind === 'library') {
mirror = await library.ensureMirror(context);
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
state = { version: 1, branch: 'main', served: f.commit, lastFetch: new Date(NOW).toISOString(),
lastError: null, diverged: null, problems: [] };
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify(state), { mode: 0o600 });
}
const setState = patch => {
state = { ...state, ...patch };
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify(state), { mode: 0o600 });
};
const lookup = createLibraryLookup({ dataRoot, now, sync: async () => {
fetches++;
setState({ lastFetch: new Date(now()).toISOString() });
return { state, code: null };
} });
const store = new Store(dataRoot);
t.after(() => store.close());
const broker = new Broker({ store, businesses: { acme: business },
library: adapter ?? { lookup, LibraryError: library.LibraryError } });
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'r224', harness: 'pi' });
if (claimed) broker.request(agent, { verb: 'role.claim' });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const reader = broker.bindReader({ business: 'acme' }), tester = broker.bindTester({ business: 'acme' });
const call = (verb, args = {}, cap = agent) => Promise.resolve().then(() => broker.request(cap, { verb, args }));
return { ...f, dataRoot, business, context, mirror, store, broker, agent, human, reader, tester,
call, lookup, setState, fetches: () => fetches };
}
test('all five agent verbs read the configured served commit through real queries', async t => {
const f = await world(t);
const [find, get, search, manuscript, universe] = await Promise.all(VERBS.map((verb, i) => f.call(verb, ARGS[i])));
for (const result of [find, get, search, manuscript, universe]) assert.equal(result.commit, f.commit);
assert.deepEqual(find.results.map(x => x.path), ['Book One/Characters/Tomas.md']);
assert.match(JSON.stringify(get.lines), /He runs the ferry/);
assert.deepEqual(get.links_out, []); // The only target is in another universe.
assert.ok(search.results.length > 0);
assert.ok(search.results.every(x => !x.path.startsWith('Second World/') && x.scope === 'canon'));
assert.equal(manuscript.path, 'Book One/Chapters/01.md');
assert.deepEqual(universe.books, ['Book One']);
assert.equal(f.fetches(), 0);
const playground = await f.call('library.find', { universe: 'harbor', query: '', scope: 'playground' });
assert.deepEqual(playground.results.map(x => x.path), ['Book One/Playground/What If.md']);
assert.equal(await f.call('library.get', { universe: 'harbor', path: 'Second World/Characters/Elsewhere.md' }), null);
});
test('each verb refuses missing universe and unknown keys before host work', async t => {
let calls = 0;
const f = await world(t, { adapter: { lookup: () => { calls++; } } });
for (const [i, verb] of VERBS.entries()) {
const { universe, ...missing } = ARGS[i];
await assert.rejects(f.call(verb, missing), { code: 'invalid-request' });
for (const extra of [{ repo: '/elsewhere' }, { commit: 'b'.repeat(40) }, { business: 'other' }, { accept: 'b'.repeat(40) }])
await assert.rejects(f.call(verb, { ...ARGS[i], ...extra }), { code: 'invalid-request' });
await assert.rejects(f.call(verb, { ...ARGS[i], universe: '' }), { code: 'invalid-request' });
}
assert.equal(calls, 0);
});
test('no library, missing host adapter and undeclared project refuse library-not-configured', async t => {
const noLibrary = await world(t, { projects: {} });
const noAdapter = await world(t, { adapter: {} });
const f = await world(t);
for (const [i, verb] of VERBS.entries()) {
await assert.rejects(noLibrary.call(verb, ARGS[i]), { code: 'library-not-configured' });
await assert.rejects(noAdapter.call(verb, ARGS[i]), { code: 'library-not-configured' });
await assert.rejects(f.call(verb, { ...ARGS[i], project: 'other' }), { code: 'library-not-configured' });
}
f.business.projects.second = mappingEntry();
await assert.rejects(f.call('library.get', GET), { code: 'invalid-request' });
assert.equal((await f.call('library.get', { ...GET, project: 'books' })).commit, f.commit);
});
test('reads need the current holder on each call; human/tester/reader are not agents', async t => {
const f = await world(t, { claimed: false });
for (const [i, verb] of VERBS.entries()) {
await assert.rejects(f.call(verb, ARGS[i]), { code: 'not-holder' });
await assert.rejects(f.call(verb, ARGS[i], f.human), { code: 'agent-required' });
await assert.rejects(f.call(verb, ARGS[i], f.tester), { code: 'tester-refused' });
await assert.rejects(f.call(verb, ARGS[i], f.reader), { code: 'read-only' });
}
f.broker.request(f.agent, { verb: 'role.claim' });
assert.equal((await f.call('library.get', GET)).commit, f.commit);
f.broker.request(f.agent, { verb: 'role.release' });
await assert.rejects(f.call('library.get', GET), { code: 'not-holder' });
});
test('releasing a claim during an async lookup fences the response', { timeout: 30_000 }, async t => {
let release, entered;
const started = new Promise(resolve => { entered = resolve; });
const f = await world(t, { adapter: { lookup: async () => {
entered(); await new Promise(resolve => { release = resolve; }); return { lines: [[1, 'not returned']] };
} } });
const pending = f.call('library.get', GET);
await started;
f.broker.request(f.agent, { verb: 'role.release' });
release();
await assert.rejects(pending, { code: 'not-holder' });
});
test('a business library removed during an async read cannot leak its result', { timeout: 30_000 }, async t => {
let release, entered;
const started = new Promise(resolve => { entered = resolve; });
const f = await world(t, { adapter: { lookup: async () => {
entered(); await new Promise(resolve => { release = resolve; }); return { lines: [[1, 'not returned']] };
} } });
const pending = f.call('library.get', GET);
await started;
delete f.business.projects.books;
release();
await assert.rejects(pending, { code: 'library-not-configured' });
});
test('freshness boundary, unknown last fetch and failed fetch still use immutable served state', async t => {
const f = await world(t);
f.setState({ lastFetch: new Date(NOW - 60_000).toISOString() });
await f.call('library.get', GET);
assert.equal(f.fetches(), 0);
f.setState({ lastFetch: new Date(NOW - 60_001).toISOString() });
await f.call('library.get', GET);
assert.equal(f.fetches(), 1);
f.setState({ lastFetch: null });
await f.call('library.universe', { universe: 'harbor' });
assert.equal(f.fetches(), 2);
let attempted = 0, clock = NOW;
const failing = createLibraryLookup({ dataRoot: f.dataRoot, now: () => clock, sync: async () => {
attempted++;
f.setState({ lastError: { code: 'library-fetch-failed', at: new Date(clock).toISOString() } });
return { code: 'library-fetch-failed' };
} });
f.setState({ lastFetch: null });
const result = await failing(f.context, { method: 'get', args: GET, authorize() {} });
assert.equal(attempted, 1);
assert.equal(result.commit, f.commit);
for (const elapsed of [1, 59_999, 60_000]) {
clock = NOW + elapsed;
const retry = await failing(f.context, { method: 'get', args: GET, authorize() {} });
assert.equal(retry.commit, f.commit, 'serve the old pin during the failure cooldown');
assert.equal(attempted, 1, 'recent lastError prevents another sync even though lastFetch is null');
}
clock = NOW + 60_001;
assert.equal((await failing(f.context, { method: 'get', args: GET, authorize() {} })).commit, f.commit);
assert.equal(attempted, 2, 'retry only after the failed-attempt interval expires');
});
test('an unreadable sync state fails closed; a busy sync keeps serving the existing pin', async t => {
const f = await world(t);
const lookup = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => {
throw new library.LibraryError('library-sync-busy', 'another fetch');
} });
f.setState({ lastFetch: null });
assert.equal((await lookup(f.context, { method: 'get', args: GET, authorize() {} })).commit, f.commit);
writeFileSync(join(f.mirror, library.SYNC_STATE_FILE), '{bad json', { mode: 0o600 });
await assert.rejects(f.call('library.get', GET), { code: 'library-state-invalid' });
});
test('a stale lookup sees the commit published by its sync, not the prior served commit', async t => {
const f = await world(t);
writeFileSync(join(f.repo, GET.path), note('character', 'Tomas', 'Changed ferry.'));
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'second fixture');
const next = git(f.repo, 'rev-parse', 'HEAD');
const lookup = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => {
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', f.mirror, 'HEAD:refs/heads/served');
f.setState({ served: next, lastFetch: new Date(NOW).toISOString() });
return { code: null };
} });
f.setState({ lastFetch: null });
const result = await lookup(f.context, { method: 'get', args: GET, authorize() {} });
assert.equal(result.commit, next);
assert.match(JSON.stringify(result.lines), /Changed ferry/);
});
test('real socket awaits lookup results and rechecks rejected query arguments', { timeout: 30_000 }, async t => {
const f = await world(t);
const path = join(f.root, 'lookup.sock');
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
const client = new Client({ path, cap: f.agent });
const result = await client.call('library.get', GET);
assert.equal(result.commit, f.commit);
await assert.rejects(client.call('library.find', { universe: 'harbor', query: '', scope: 'anything' }), /query-argument-invalid/);
});
test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => {
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot);
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() },
arbiters: { technical: 'coder', delivery: 'coder' },
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close());
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify({ version: 1, branch: 'main', served: f.commit,
lastFetch: new Date().toISOString(), lastError: null, diverged: null, problems: [] }), { mode: 0o600 });
const cap = runtime.broker.bindLaunch({ business: 'acme', role: 'coder', run: 'r224-runtime', harness: 'pi' });
runtime.broker.request(cap, { verb: 'role.claim' });
assert.equal((await runtime.broker.request(cap, { verb: 'library.get', args: GET })).commit, f.commit);
await assert.rejects(runtime.broker.request(cap, { verb: 'library.file', args: { project: 'books', path: GET.path } }),
{ code: 'human-required' });
});
test('C2-05c broker forwards supporting scope and returns the served blob without adding write verbs', async t => {
const projects = { books: mappingEntry() };
projects.books.universes.harbor.supporting = ['Book One/Support/'];
const path = 'Book One/Support/Helper.md';
const f = await world(t, { projects, files: { ...TEXTS, [path]: note('summary', 'Helper', 'Supporting fixture.') } });
assert.deepEqual((await f.call('library.find', { universe: 'harbor', query: '', scope: 'supporting' })).results.map(x => x.path), [path]);
assert.deepEqual((await f.call('library.search', { universe: 'harbor', text: 'Supporting fixture', scope: 'supporting' })).results.map(x => x.path), [path]);
assert.equal(await f.call('library.get', { universe: 'harbor', path }), null);
const result = await f.call('library.get', { universe: 'harbor', path, scope: 'supporting' });
assert.equal(result.scope, 'supporting');
assert.equal(result.commit, f.commit);
assert.equal(result.blob, git(f.mirror, 'rev-parse', `${f.commit}:${path}`));
assert.equal(await f.call('library.get', { universe: 'second', path, scope: 'supporting' }), null);
await assert.rejects(f.call('library.manuscript', { universe: 'harbor', book: 'Book One', scope: 'supporting' }), { code: 'query-argument-invalid' });
});
+1 -1
View File
@@ -21,7 +21,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
+1 -44
View File
@@ -8,7 +8,6 @@ import { join, dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
import { requireId, sha256, canonicalJson } from "./util.mjs";
import { checkReplyLimits } from "../../library/src/limits.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
@@ -81,33 +80,6 @@ function changes(old, next, path = "") {
}];
}
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
// the business's human as the file stood before the write, and no agent or the tester under that
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
function replyLimitsFence(old, document, actor) {
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
// The reason names each changed key and its value in force (the brief's M11).
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
const before = old.replyLimits, after = document?.replyLimits;
const named = plain(before) && plain(after)
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
}
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
// M11 fence refuses any actor but the human.
export function setReplyLimits(writer, id, args, actor) {
const replyLimits = checkReplyLimits(args?.replyLimits);
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
const { document } = writer.read(id);
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
}
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const evidence = join(dir, "businesses", ".history");
const logFile = id => join(evidence, id, "changes.jsonl");
@@ -121,19 +93,6 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
return { document, revision: sha256(bytes) };
}
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
// file that fails validation still yields its limits. { readable: false } when the file is missing,
// unreadable, not JSON or not an object.
function readRaw(id) {
try {
const document = JSON.parse(regular(businessFilePath(id, dir)));
return document !== null && typeof document === "object" && !Array.isArray(document)
? { readable: true, document } : { readable: false };
} catch {
return { readable: false };
}
}
function withLock(id, fn) {
const parent = join(dir, "businesses");
directory(evidence);
@@ -232,7 +191,6 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// mapping, including library playgrounds and hybrid universes.
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
replyLimitsFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
@@ -240,7 +198,6 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const written = JSON.parse(bytes);
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
replyLimitsFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
const after = sha256(bytes);
const transaction = randomUUID();
@@ -288,5 +245,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
return { logFile, read, readRaw, write, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
}
@@ -27,6 +27,10 @@ test('C2-04: library mappings load without roots, remain frozen, and retain mapp
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
// C2-01d: a supporting prefix inside another universe's book refuses with its own code.
doc.projects.books = mappingEntry();
doc.projects.books.universes.harbor.supporting = ['Second World/Notes/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-supporting-other-universe/);
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
});
@@ -1,76 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { organizationWriter, setReplyLimits } from '../src/organization.mjs';
import { validateBusinessDocument } from '../src/business.mjs';
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
function fixture(t, human = 'jason') {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root); doc.human = human; doc.replyLimits = { requireCitation: false, citationsPerReply: 20 };
const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc), rolesDir = rolesCopy(root);
const writer = organizationWriter({ dir, rolesDir });
const next = structuredClone(doc); next.vars['tracker.pollSeconds'] = 61;
writer.write('acme', next, { revision: writer.read('acme').revision, actor: human });
return { root, file, writer, rolesDir, log: writer.logFile('acme') };
}
function refused(f, actor, change) {
const before = readFileSync(f.file), log = readFileSync(f.log), { document, revision } = f.writer.read('acme');
change(document);
assert.throws(() => f.writer.write('acme', document, { revision, actor }), { code: 'organization-reply-limits-human-only' });
assert.deepEqual(readFileSync(f.file), before); assert.deepEqual(readFileSync(f.log), log);
}
test('C2-06 M11 human replacement works; omitted settings return to defaults', t => {
const f = fixture(t);
setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits: { requireCitation: true } }, 'jason');
assert.deepEqual(f.writer.read('acme').document.replyLimits, { requireCitation: true });
setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits: {} }, 'jason');
assert.deepEqual(f.writer.read('acme').document.replyLimits, {});
});
test('C2-06 M11 agents and tester cannot change settings, including by changing human', t => {
const f = fixture(t);
for (const actor of ['chief-of-staff.chief', 'tester', 'coder.coder'])
refused(f, actor, d => { d.replyLimits.requireCitation = true; });
refused(f, 'chief-of-staff.chief', d => { d.human = 'chief-of-staff.chief'; d.replyLimits.requireCitation = true; });
const dotted = fixture(t, 'coder.coder');
refused(dotted, 'coder.coder', d => { d.replyLimits.citationsPerReply = 1; });
});
test('C2-06 M11 serialization cannot replace settings after the first fence', t => {
const f = fixture(t);
refused(f, 'chief-of-staff.chief', d => {
Object.defineProperty(d.replyLimits, 'toJSON', { value: () => ({ requireCitation: true }) });
});
});
// E1: unlike tester/role actors, mallory passes the other actor-shape exclusions.
// Comparing document.human instead of old.human must fail this case.
test('C2-06 M11 an unrelated actor cannot appoint itself human and change reply limits', t => {
const f = fixture(t);
refused(f, 'mallory', d => { d.human = 'mallory'; d.replyLimits.requireCitation = true; });
});
test('C2-06 invalid setter values preserve the business and changes.jsonl', t => {
const f = fixture(t), before = readFileSync(f.file), log = readFileSync(f.log);
for (const replyLimits of [{ citationsPerReply: 0 }, { citationsPerReply: 1001 }, { requireCitation: 'true' }, { invented: true }, []]) {
assert.throws(() => setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits }, 'jason'), { code: 'invalid-request' });
assert.deepEqual(readFileSync(f.file), before); assert.deepEqual(readFileSync(f.log), log);
}
});
test('C2-06 validator accepts hand-edited settings; raw reader fails closed on invalid files', t => {
const f = fixture(t), doc = f.writer.read('acme').document;
for (const replyLimits of ['true', 1, null, [true], {}, { requireCitation: 'true' }, { citationsPerReply: 0 }]) {
const next = { ...doc, replyLimits };
assert.doesNotThrow(() => validateBusinessDocument(next, f.file, { rolesDir: f.rolesDir }));
writeJson(f.file, next); assert.deepEqual(f.writer.readRaw('acme').document.replyLimits, replyLimits);
}
for (const bad of ['{broken', 'null', '[]', '1']) {
writeFileSync(f.file, bad); assert.deepEqual(f.writer.readRaw('acme'), { readable: false });
}
rmSync(f.file); assert.deepEqual(f.writer.readRaw('acme'), { readable: false });
});
-6
View File
@@ -959,12 +959,6 @@ writer adds `since`, the time the limit was set, and keeps it on later saves.
A patch that sends a different `since` is refused. Only these paths are token
limits; the agent limits above are never read as one, whatever their names.
`mosaic limits replies --file LIMITS [--business ID]` replaces the citation settings
through the human-only `library.limits.set` verb. LIMITS is a JSON object with optional
`requireCitation` (boolean, default false) and `citationsPerReply` (integer 1–1000,
default 20). Omitted keys use their defaults; unknown keys and invalid values refuse.
The next library reply uses the new settings without restarting the bus.
`mosaic limits reset WORD... [--business ID]` moves one token limit's `since`
to now, so its count restarts. The path is one word per level, as the plain
output prints it: `mosaic limits reset tokens models anthropic claude-opus-5-5`.
+1
View File
@@ -528,6 +528,7 @@ export function createLauncher({
contract: readFileSync(resolved.contract, "utf8"),
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
library: Object.values(b.projects ?? {}).some(project => project.kind === "library"),
credentials,
session: { run, launchedBy, workspace: inSandbox, toolSocket, turnMarker, checkouts: inSandbox === workspace ? checkouts : checkouts.map((c) => ({ ...c, path: join(inSandbox, relative(workspace, c.path)) })) },
harness,
+4 -11
View File
@@ -7,10 +7,9 @@ import { CliError } from "./errors.mjs";
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
// C2-06 M11: `replies --file LIMITS` replaces replyLimits with the file's object, at the current revision.
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] [--tester] | set --file PATCH [--business ID] | " +
"reset PATH... [--business ID] | replies --file LIMITS [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
"PATCH keys: revision, limits, models, instances, reset, assistants; LIMITS keys: requireCitation, citationsPerReply";
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
"PATCH keys: revision, limits, models, instances, reset, assistants";
export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else.";
@@ -68,9 +67,8 @@ export async function limitsCommand(args, io, callFor) {
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
return;
}
const fileVerb = verb === "set" || verb === "replies";
if (!["view", "set", "reset", "replies"].includes(verb) || values["--models"] || values["--roles-dir"] ||
(!fileVerb && values["--file"]) || (fileVerb && !values["--file"]) ||
if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] ||
(verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) ||
(verb === "reset" && parsed.positional.length < 2)) {
throw new CliError(LIMITS_USAGE, 4);
}
@@ -80,11 +78,6 @@ export async function limitsCommand(args, io, callFor) {
// result's `tokens` lists each token limit: counted, not-enforced or check-failed.
let result;
if (verb === "set") result = await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")));
else if (verb === "replies") {
const replyLimits = JSON.parse(readFileSync(values["--file"], "utf8"));
const { revision } = await call("limits", {});
result = await call("library.limits.set", { revision, replyLimits });
}
else if (verb === "reset") {
const { revision } = await call("limits", {});
result = await call("limits.set", { revision, reset: [parsed.positional.slice(1)] });
-23
View File
@@ -1,23 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { limitsCommand } from '../src/limits.mjs';
test('C2-06 CLI replies reads revision and sends only the JSON settings to the human verb', async t => {
const dir = mkdtempSync(join(tmpdir(), 'reply-cli-')); t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, 'settings.json'); writeFileSync(file, JSON.stringify({ requireCitation: true, citationsPerReply: 7 }));
const calls = [], output = [];
await limitsCommand(['replies', '--file', file, '--business', 'acme'], { stdout: { write: s => output.push(s) } }, parsed => {
assert.equal(parsed.values['--business'], 'acme');
return async (verb, args) => { calls.push([verb, args]); return verb === 'limits' ? { revision: 'r1' } : { revision: 'r2' }; };
});
assert.deepEqual(calls, [['limits', {}], ['library.limits.set', { revision: 'r1', replyLimits: { requireCitation: true, citationsPerReply: 7 } }]]);
assert.match(output.join(''), /r2/);
});
test('C2-06 CLI replies requires a file and refuses tester writes before a bus call', async () => {
for (const args of [['replies'], ['replies', '--tester', '--file', 'unused'], ['replies', '--file', 'unused', '--models', 'unused']])
await assert.rejects(limitsCommand(args, { stdout: { write() {} } }, () => { throw Error('bus touched'); }), { exitCode: 4 });
});
+22
View File
@@ -27,6 +27,7 @@ import { bootConfig, loadSystem } from "../src/config.mjs";
import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
import { mappingEntry } from "../../library/tests/helpers.mjs";
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
@@ -529,3 +530,24 @@ test('C2-09a: terminating one creative thread does not signal an unselected peer
await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
});
// Row224: the launcher must derive library presence from business projects, not
// from session checkouts (a library never has an agent clone).
test("C2-05b a real launched bundle gets lookups for a library project without a library checkout", { skip, timeout: 60000 }, async t => {
const prepared = prepare(t, { idle: 10, edit: doc => {
doc.projects.books = mappingEntry();
return doc;
} });
const { f } = prepared;
const host = await start(t, prepared);
const project = host.call("project.create", { name: "library work", repo: "stack" }).id;
const thread = host.call("thread.create", { project, name: "lookup" }).id;
await ask(host.call, thread, "inspect bundle");
const run = launchLog(f).find(e => e.event === "launch" && e.thread === thread).run;
const tools = JSON.parse(readFileSync(join(f.dataRoot, "launches", "acme", run, "bundle", "tools.json"), "utf8"));
assert.deepEqual(tools.filter(tool => tool.name.startsWith("library_")).map(tool => tool.name),
["library_find", "library_get", "library_search", "library_manuscript", "library_universe"]);
const [session] = threadRuns(f, thread);
assert.ok(session);
assert.ok(!session.checkouts?.some(checkout => checkout.project === "books"));
});
+3 -2
View File
@@ -292,6 +292,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
// resolved resolveInstance(...) output
// contract the role contract's text
// arbiter true when the business names this instance as an arbiter
// library true when the trusted business configuration has a library project
// tracker true when the broker has task verbs for the business
// credentials the broker's credentialStatus(...) for the instance (metadata only)
// session { run, launchedBy, workspace, toolSocket, turnMarker, checkouts }
@@ -309,11 +310,11 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
// (row 213); an instance with persona.sha256 and no personaDir refuses
// Returns { manifest, files, policy, tools, env } with absolute paths; env is the module's
// adapter variables, which the host writes into session.json.
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) {
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, library = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) {
if (route !== "primary" && route !== "fallback") throw new BundleError(`route ${JSON.stringify(route)} isn't primary or fallback`);
const model = sessionModel(resolved, models, route);
const web = webPlan(resolved, model.harness, harness.builtinWeb());
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
const tools = typedTools({ resolved, arbiter, tracker, library, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
const untyped = untypedActions(resolved, tools);
const persona = readPersona(resolved, personaDir);
mkdirSync(dir, { recursive: true, mode: 0o700 });
+4 -57
View File
@@ -38,7 +38,6 @@ import { createServer, connect } from "node:net";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { Client } from "../../bus/src/client.mjs";
import { GUARD_CODES, FIXED_TEXTS } from "../../library/src/reply-protocol.mjs";
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
export const EXIT = Object.freeze({ stopped: 0, founder: 20, claim: 21, session: 22, broker: 23, usage: 2 });
@@ -459,9 +458,8 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
return EXIT.founder;
}
const client = new Client({ path: session.brokerSocket, cap, timeout: 30_000 });
let library = false;
try {
library = (await client.call("role.claim"))?.library === true;
await client.call("role.claim");
} catch (e) {
// No answer is not a refusal: the broker is down or the outcome is unknown.
if (e.code === "outcome-unknown") {
@@ -584,60 +582,9 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
try {
if (m.class !== "RESULT") {
if (!library) {
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
unslop.sent();
} else {
const send = async (answer) => {
const raw = answer.ok ? answer.text || FIXED_TEXTS.empty : FIXED_TEXTS.failed;
const fixed = Object.values(FIXED_TEXTS).includes(raw);
let body = raw;
if (!fixed) {
let value;
try { value = JSON.parse(raw); } catch {}
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
// Malformed objects still reach the guard and its reply-invalid refusal.
if (typeof value.text === "string") value.text += unslop.note();
body = JSON.stringify(value);
} else body = clip(raw) + unslop.note();
}
try {
await client.call("message.send", { to: m.from_role, body, class: "RESULT", in_reply_to: m.id });
if (!fixed) unslop.sent();
} catch (error) {
// The existing broker body cap is smaller than the socket's 64 KiB cap.
// Both follow the one-retry rule, without changing non-library sends.
if (error.code === "invalid-request" && body.length > 32768) {
error = Object.assign(new Error("request-too-large"), { code: "request-too-large" });
}
throw error;
}
};
try { await send(turn); }
catch (error) {
if (!GUARD_CODES.includes(error.code) && error.code !== "request-too-large") throw error;
log(`runner: reply to ${m.id} refused: ${error.code}${error.reason ? `: ${error.reason}` : ""}`);
const reason = error.code === "request-too-large"
? "The message exceeds the 64 KiB request limit or the 32768-character body limit. Send a shorter answer."
: error.reason ?? "The reply did not pass the citation check.";
++n;
try { await client.call("usage.begin", { turn: `${m.id}:retry` }); }
catch (e) { log(`runner: usage baseline unavailable: ${e.code ?? "failed"}`); }
const retry = await runTurn({ session, request: `${block ?? ""}\n${turnRequest(m)}\n\nYour answer was refused with ${error.code}: ${reason}\nReply again in this same session.`, n, env: turnEnv, onChild: (c) => (child = c) });
unslop.read();
try { await client.call("usage.end", { turn: `${m.id}:retry` }); }
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
if (stopping) break loop;
if (!retry.ok) log(`runner: retry turn ${n} for message ${m.id} failed: ${retry.reason}`);
try { await send(retry); }
catch (again) {
if (!GUARD_CODES.includes(again.code) && again.code !== "request-too-large") throw again;
log(`runner: retry to ${m.id} refused: ${again.code}`);
await send({ ok: true, text: again.code === "request-too-large" ? FIXED_TEXTS.tooLong : FIXED_TEXTS.uncited });
}
}
}
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
unslop.sent();
}
await client.call("message.read", { id: m.id });
} catch (e) {
+35 -2
View File
@@ -78,9 +78,10 @@ const TASK_TOOLS = {
const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION", "BLOCK"];
// typedTools({ resolved, arbiter, tracker, projects, land }) -> [{ name, verb, action, description, parameters }]
// typedTools({ resolved, arbiter, tracker, projects, land, library }) -> [{ name, verb, action, description, parameters }]
// resolved resolveInstance(...) output for the session's instance
// arbiter true when the business names this instance as an arbiter
// library true when the trusted business configuration has a library project
// tracker true when the broker has task verbs for the business
// projects the project ids the session has a clone of
// land true when the role and thread hold a merge decision a landing could use (landHeld)
@@ -88,7 +89,7 @@ const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESU
// `verb` is the broker verb; the `launch` tool has verb "launch" and goes
// to the host instead, and "working.push", "working.fetch" and
// "working.land" go to the host's git socket.
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false }) {
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false, library = false }) {
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
const tools = [];
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
@@ -222,6 +223,33 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
url: str("The https url to fetch", { maxLength: 2048 }),
}, ["url"]));
}
// Row224: no library clone or token reaches a session. The broker chooses the served
// commit and requires a universe on every call; cursors page without cutting replies.
if (library) {
const universe = str("Universe ID in the configured library", { minLength: 1 });
const project = str("Library project ID; omit only when the business has one library");
const scope = str("canon by default; playground and supporting are opt-in", { enum: ["canon", "playground", "supporting"] });
const manuscriptScope = str("canon by default; playground is opt-in", { enum: ["canon", "playground"] });
const book = str("Book folder from library_universe");
const offset = { type: "integer", minimum: 0, description: "next from the previous page" };
const line = { type: "integer", minimum: 1, description: "One-based line number" };
const base = { universe, project };
add("library_find", "library.find", null, "Find names in one universe, at the served commit. At most 50 results; continue with next as offset. Playground and supporting are opt-in.", obj({
...base, query: str("Case-insensitive name or filename substring; empty matches all"),
type: str("Mapped type", { enum: ["character", "place", "research", "tracker", "summary"] }),
book, scope, offset,
}, ["universe", "query"]));
add("library_get", "library.get", null, "Read a note and its blob at the served commit, canon by default. Playground/supporting paths need matching scope; mismatch returns null. next pages lines; links_next pages visible links in supporting/playground/canon order.", obj({
...base, path: str("Mapped note path relative to the library"), scope, from: line, links_from: offset,
}, ["universe", "path"]));
add("library_search", "library.search", null, "Search text in one universe at the served commit. At most 50 hits; use next as offset. Playground and supporting are opt-in.", obj({
...base, text: str("Nonempty case-insensitive substring", { minLength: 1 }), book, scope, offset,
}, ["universe", "text"]));
add("library_manuscript", "library.manuscript", null, "Read one chapter of a book at the served commit, at most 400 lines. Omit chapter for the first; continue with next or next_chapter.", obj({
...base, book, scope: manuscriptScope, chapter: str("Chapter filename without extension"), from: line, to: line,
}, ["universe", "book"]));
add("library_universe", "library.universe", null, "Read a universe's mapping at the served commit: books, types, playground prefixes, and hybrid sources/constraints/notes or draft metadata.", obj(base, ["universe"]));
}
add("read_spec", "spec.read", null, "Read a project's committed north-star spec and its file history. commit is an optional full reachable commit ID. The file commit, rather than HEAD, is what a decision cites. confirmed says the file commit was authored by the business's human.", obj({
project: str("Project ID from the business file"),
commit: str("Optional full historical commit ID", { pattern: "^[0-9a-f]{40}$" }),
@@ -277,6 +305,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
if (held.has("task.create"))
add("list_requirements", "requirements", null, "List the approved PRD's requirement ids, each with a one-line title. task_create takes only these. When none fits a request, raise a prd.approve decision instead of creating the task.", obj({}));
}
// mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The
// C2-08 web grant still supplies broker web reads to companion and research only.
if (CREATIVE_DEFINITIONS.includes(resolved.definition))
return tools.filter(tool => tool.name === "send_message" || tool.name.startsWith("library_") ||
tool.name === "web_search" || tool.name === "web_fetch");
return tools;
}
@@ -1,144 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { serve } from '../../bus/src/server.mjs';
import { FIXED_TEXTS } from '../../library/src/reply-protocol.mjs';
import { GuardError } from '../../library/src/guard.mjs';
import { citationWorld, citedBody, fixtureCite, guardCases } from '../../bus/tests/citation-helpers.mjs';
const RUNNER = new URL('../src/runner.mjs', import.meta.url).pathname;
const ADAPTER = new URL('./fixtures/citation-adapter.mjs', import.meta.url).pathname;
async function until(fn) {
const stop = Date.now() + 15000;
while (Date.now() < stop) { const value = fn(); if (value) return value; await new Promise(r => setTimeout(r, 15)); }
throw Error('fixture timed out');
}
async function setup(t, stepsFor, { library = true, requireCitation = true, sendAuthority = true, guard } = {}) {
const f = citationWorld(t, { library, sendAuthority, guard }), runDir = join(f.root, 'run'), bundle = join(runDir, 'bundle');
mkdirSync(bundle, { recursive: true }); mkdirSync(join(f.root, 'ws'));
const steps = stepsFor(f.commit), brokerSocket = join(f.root, 'broker.sock');
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation } }));
const server = await serve({ broker: f.broker, path: brokerSocket });
writeFileSync(join(runDir, 'fixture-steps.json'), JSON.stringify(steps));
const adapter = join(runDir, 'adapter.sh'); writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`);
writeFileSync(join(bundle, 'prompt.md'), 'Fixture only.\n');
writeFileSync(join(bundle, 'tools.json'), JSON.stringify([{ name: 'send_message', verb: 'message.send', action: 'message.send' }]));
writeFileSync(join(bundle, 'policy.json'), JSON.stringify({ needs: [], credentials: [] }));
writeFileSync(join(runDir, 'session.json'), JSON.stringify({ business: 'acme', instance: 'coder', run: 'coder-run', harness: 'pi',
provider: 'fixture', model: 'fixture', brokerSocket, workspace: join(f.root, 'ws'), sessionDir: join(runDir, 'session'), adapter,
bundle: { prompt: join(bundle, 'prompt.md'), tools: join(bundle, 'tools.json'), policy: join(bundle, 'policy.json') },
toolSocket: join(runDir, 'tools.sock'), turnMarker: join(runDir, 'turn.done'), turnEnd: 'marker', harnessEnv: {}, pollInterval: 20, turnTimeout: 10 }));
const child = spawn(process.execPath, [RUNNER, runDir], { stdio: ['pipe', 'ignore', 'pipe'], env: { PATH: process.env.PATH } });
let log = ''; child.stderr.on('data', b => { log += b; }); child.stdin.on('error', () => {}); child.stdin.end(JSON.stringify({ cap: f.agent }) + '\n');
const exited = once(child, 'exit');
t.after(async () => { child.kill('SIGTERM'); await exited; await server.close(); });
await until(() => f.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const ask = async () => {
const sent = await f.call(f.human, 'message.send', { to: 'coder', body: 'Fixture question.', class: 'REQUEST' });
const reply = await until(() => f.store.get('SELECT * FROM messages WHERE in_reply_to=?', sent.id));
await until(() => f.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
return reply;
};
return { ...f, runDir, ask, log: () => log, count: () => Number(readFileSync(join(runDir, 'fixture-count'), 'utf8')) };
}
for (const code of ['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid', 'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']) {
test(`C2-06 RESULT ${code} retries once, delivers only the valid answer and names reason`, async t => {
const f = await setup(t, commit => [{ text: guardCases(commit).find(c => c[0] === code)[1] }, { text: citedBody([fixtureCite(commit)]) }]);
const reply = await f.ask(); assert.equal(reply.body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 1);
const requests = readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.match(requests[1], new RegExp(code)); assert.match(requests[1], /refused with/);
assert.match(f.log(), new RegExp(code));
});
}
test('C2-06 RESULT two guard refusals use the citation fixed text and exactly one retry', async t => {
const f = await setup(t, () => [{ text: 'uncited' }, { text: 'uncited again' }]);
assert.equal((await f.ask()).body, FIXED_TEXTS.uncited); assert.equal(f.count(), 2);
});
for (const size of [34000, 70000]) {
test(`C2-06 RESULT JSON ${size} characters retries with a shorter valid answer`, async t => {
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(size), citations: [fixtureCite(commit)] }) }, { text: citedBody([fixtureCite(commit)]) }]);
assert.equal((await f.ask()).body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
assert.match(readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8'), /64 KiB/);
});
}
test('C2-06 RESULT two oversized JSON bodies use the length fixed text with citations required', async t => {
const f = await setup(t, commit => Array(2).fill({ text: JSON.stringify({ text: 'x'.repeat(70000), citations: [fixtureCite(commit)] }) }));
assert.equal((await f.ask()).body, FIXED_TEXTS.tooLong); assert.equal(f.count(), 2);
});
test('C2-06 multibyte plain bodies also receive the one request-size retry', async t => {
const f = await setup(t, commit => [{ text: '界'.repeat(32000) }, { text: citedBody([fixtureCite(commit)]) }]);
assert.equal((await f.ask()).body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
});
test('C2-06 two multibyte plain bodies use the length fallback', async t => {
const f = await setup(t, () => Array(2).fill({ text: '界'.repeat(32000) }));
assert.equal((await f.ask()).body, FIXED_TEXTS.tooLong); assert.equal(f.count(), 2);
});
test('C2-06 RESULT a length refusal then citation refusal shares one retry and citation fallback', async t => {
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(70000), citations: [fixtureCite(commit)] }) }, { text: 'uncited' }]);
assert.equal((await f.ask()).body, FIXED_TEXTS.uncited); assert.equal(f.count(), 2);
});
test('C2-06 fixed empty/failed replies defer unslop note until the next cited reply', async t => {
const f = await setup(t, commit => [{ text: '', unslop: true }, { fail: true }, { text: citedBody([fixtureCite(commit)]) }]);
assert.equal((await f.ask()).body, FIXED_TEXTS.empty); assert.equal((await f.ask()).body, FIXED_TEXTS.failed);
const reply = JSON.parse((await f.ask()).body);
assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.match(reply.text, /unslop/i); assert.equal(f.count(), 3);
});
test('C2-06 cited JSON above the old 32000 clip retains its citations and text', async t => {
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(32200), citations: [fixtureCite(commit)] }) }]);
const reply = JSON.parse((await f.ask()).body); assert.equal(reply.text.length, 32200); assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.equal(f.count(), 1);
});
test('C2-06 a flagged cited answer keeps the note inside text and citations whole', async t => {
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]), unslop: true }]);
const reply = JSON.parse((await f.ask()).body);
assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.match(reply.text, /unslop/i); assert.equal(f.count(), 1);
});
test('C2-06 send_message typed tool runs every guard code before the valid RESULT', async t => {
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]), tools: guardCases(commit).map(([, body]) => ({ to: 'human', body })) }]);
await f.ask(); const results = readFileSync(join(f.runDir, 'fixture-tools.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(results.length, 7); results.forEach((r, i) => { assert.equal(r.ok, false); assert.match(r.error, new RegExp(guardCases(f.commit)[i][0])); });
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 1);
});
test('C2-06 outside library the old failed text, clip and unslop behavior hold', async t => {
const f = await setup(t, () => [{ fail: true }, { text: 'x'.repeat(40000), unslop: true }], { library: false });
assert.match((await f.ask()).body, /^The turn failed:/);
const reply = (await f.ask()).body; assert.match(reply, /answer cut at 32000 characters/); assert.match(reply, /unslop/i); assert.equal(f.count(), 2);
});
test('C2-06 outside library a refused send keeps the existing no-retry behavior', async t => {
const f = await setup(t, () => [{ text: 'answer without send authority' }], { library: false, sendAuthority: false });
await f.call(f.human, 'message.send', { to: 'coder', body: 'Fixture question.', class: 'REQUEST' });
await until(() => /runner: reply to .* failed:/.test(f.log()));
assert.equal(f.count(), 1); assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 0);
assert.equal(existsSync(join(f.runDir, 'fixture-requests.jsonl')), true);
});
// E4: the agent must receive the availability reason in its actual retry turn,
// rather than being told only that the source citation was invalid.
test('C2-06 the retry turn delivers citation-unavailable reason to the agent', async t => {
let sends = 0;
const reason = 'The library is busy or unavailable. Retry the answer when the library is available.';
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]) }, { text: citedBody([fixtureCite(commit)]) }], {
guard: async () => { if (sends++ === 0) throw new GuardError('citation-unavailable', reason); return null; },
});
await f.ask(); assert.equal(f.count(), 2);
const requests = readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(requests[1].includes(`citation-unavailable: ${reason}`), true);
});
-21
View File
@@ -1,21 +0,0 @@
// A fixture-only scripted harness. No provider or real credential is used.
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { callTool } from '../../src/tools.mjs';
const root = dirname(process.env.MOSAIC_TURN_MARKER), countFile = join(root, 'fixture-count');
const n = existsSync(countFile) ? Number(readFileSync(countFile, 'utf8')) : 0;
const steps = JSON.parse(readFileSync(join(root, 'fixture-steps.json'), 'utf8'));
const step = steps[n] ?? { fail: true };
writeFileSync(countFile, String(n + 1));
appendFileSync(join(root, 'fixture-requests.jsonl'), JSON.stringify(process.env.MOSAIC_REQUEST) + '\n');
if (step.unslop) appendFileSync(process.env.MOSAIC_UNSLOP_LOG, JSON.stringify({ ev: 'flagged', findings: [{ source: 'fixture', id: 'fixture' }] }) + '\n');
for (const args of step.tools ?? []) {
let result;
try { result = { ok: true, result: await callTool(process.env.MOSAIC_TOOL_SOCKET, 'send_message', args) }; }
catch (e) { result = { ok: false, error: e.message }; }
appendFileSync(join(root, 'fixture-tools.jsonl'), JSON.stringify(result) + '\n');
}
if (step.fail) process.exit(3);
process.stdout.write(step.text ?? '');
writeFileSync(process.env.MOSAIC_TURN_MARKER, 'done\n');
@@ -0,0 +1,82 @@
// Row224 M3: exact creative tool sets preserve the existing C2-08 web grants.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { typedTools } from '../src/tools.mjs';
import { buildBundle } from '../src/bundle.mjs';
import { TYPES } from '../../library/src/mapping.mjs';
import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from './helpers.mjs';
const LOOKUPS = ['library_find', 'library_get', 'library_search', 'library_manuscript', 'library_universe'];
const CREATIVE = ['companion', 'research', 'ideas', 'proofreader', 'fact-checker', 'continuity'];
const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => {
d.roles.bean = { definition, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } };
});
const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition)
? ['web_search', 'web_fetch'] : []), ...LOOKUPS];
test('M3: per-role snapshots contain six names, or eight with the C2-08 web grant', t => {
for (const definition of CREATIVE) {
const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true });
assert.deepEqual(tools.map(t => t.name), expected(definition), definition);
assert.ok(tools.every(t => t.parameters.additionalProperties === false));
}
});
test('every noncreative role gets lookups when a library is configured, not otherwise', t => {
for (const instance of ['coder', 'reviewer', 'pm', 'chief']) {
const resolved = resolvedFor(t, instance);
const ordinary = typedTools({ resolved }).map(t => t.name);
assert.ok(!ordinary.some(name => name.startsWith('library_')));
const tools = typedTools({ resolved, library: true });
assert.deepEqual(tools.filter(t => t.name.startsWith('library_')).map(t => t.name), LOOKUPS);
assert.deepEqual(tools.filter(t => !t.name.startsWith('library_')).map(t => t.name), ordinary);
}
});
test('lookup schemas require a universe even for get, include paging, and cannot select repo or commit', t => {
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
const required = [ ['universe', 'query'], ['universe', 'path'], ['universe', 'text'], ['universe', 'book'], ['universe'] ];
for (const [i, tool] of tools.filter(t => t.name.startsWith('library_')).entries()) {
assert.deepEqual(tool.parameters.required, required[i]);
assert.equal(tool.verb, LOOKUPS[i].replace('_', '.'));
assert.equal(tool.action, null);
assert.equal(tool.parameters.additionalProperties, false);
for (const key of ['repo', 'commit', 'business', 'accept']) assert.equal(Object.hasOwn(tool.parameters.properties, key), false);
}
const byName = Object.fromEntries(tools.map(t => [t.name, t]));
assert.deepEqual(byName.library_find.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
assert.deepEqual(byName.library_find.parameters.properties.type.enum, [...TYPES]);
assert.equal(byName.library_find.parameters.properties.offset.minimum, 0);
assert.equal(byName.library_get.parameters.properties.from.minimum, 1);
assert.equal(byName.library_get.parameters.properties.links_from.minimum, 0);
assert.equal(byName.library_manuscript.parameters.properties.chapter.type, 'string');
});
test('bundle flag reaches both harness tool manifests and creative prompt without generic tools', t => {
for (const definition of CREATIVE) for (const harness of ['pi', 'claude-code']) {
const dir = scratch(t), workspace = join(dir, 'ws'); mkdirSync(workspace);
const b = buildBundle({ dir: join(dir, 'bundle'), resolved: role(t, definition, harness), library: true,
contract: '# Creative\n', tracker: true, arbiter: true, skillsDir: REPO_SKILLS,
session: { run: 'r224', launchedBy: 'chief', workspace, toolSocket: join(dir, 't.sock'), turnMarker: join(dir, 'turn.done') },
harness: HARNESS[harness] });
assert.deepEqual(b.tools.map(t => t.name), expected(definition), `${definition} ${harness}`);
const serialized = JSON.parse(readFileSync(b.files.tools, 'utf8'));
assert.deepEqual(serialized.map(t => t.name), expected(definition));
const prompt = readFileSync(b.files.prompt, 'utf8');
assert.match(prompt, /library_find/);
assert.doesNotMatch(prompt, /Bus tools: [^\n]*(read_spec|list_agents|inbox|trail|write_record|raise_decision|list_tasks)/);
}
});
test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => {
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS]);
for (const name of ['library_find', 'library_search', 'library_get']) {
const tool = tools.find(t => t.name === name);
assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
assert.ok(!tool.parameters.required.includes('scope'));
}
assert.deepEqual(tools.find(t => t.name === 'library_manuscript').parameters.properties.scope.enum, ['canon', 'playground']);
});
+45 -6
View File
@@ -31,33 +31,72 @@ deep-frozen copy. `where` names the entry in messages. `fail(code, message)` def
- `user` is the bot account name from design step 3, required for sync. It is optional in the
mapping and checked with row 199's account name pattern, `[A-Za-z0-9_.-]`, 1 to 100 characters.
- `vaultPrefix` is optional, one folder name.
- `editor` is optional, `{ "name", "email" }` plus an optional `"user"`: the ident her Console saves commit under
(C2-01d). Each value is text that is non-empty after trim, with no control character
(`U+0000`-`U+001F`, `U+007F`-`U+009F`), `<` or `>`, and no whitespace or git-stripped character at
either end. `user` is her Gitea login for the `library-human` slot (sage-2925), checked as the
entry's `user` is. `editor` and its `user` come back only when given; a save without them refuses
in C2-02.
- `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`.
`manuscript` lists folder names and shares none with `types`.
- A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more
plain-universe `sources`, `constraints` (each one `{ "exclude": ... }` or
`{ "pin": { "entity", "field", "value" } }`) and `notes`, and may have `books: []`. A draft
universe has only `kind: "draft"`, `name` and one or more `playground` prefixes, and comes back as
`{ kind: "draft", name, books: [], playground }`. `kind` is absent, `"hybrid"` or `"draft"`.
`{ kind: "draft", name, books: [], playground, supporting }`. `kind` is absent, `"hybrid"` or `"draft"`.
- A playground prefix ends in `/` and has one of three levels (C2-01c). Book level lies inside one of
its own entry's book folders. Universe level, in a plain universe or a hybrid, lies outside every
book folder. A draft's prefixes all lie outside every book folder. No prefix holds another or is
listed twice, across every entry.
- Every universe kind may also have `supporting`, a list of prefixes where agents may write (C2-01d,
M12). It comes back on every universe, `[]` when absent. A supporting prefix follows the playground's
rules: it ends in `/`, has the same three levels, holds no other supporting prefix and is listed
once, across every entry.
- Between the two lists: a supporting prefix may equal a playground prefix of its own entry, and a
playground prefix may hold one of its own entry's supporting prefixes. A supporting prefix may not
hold a playground prefix, which scope would then never reach, and may not equal, hold or lie inside
another entry's playground prefix.
Refusals:
| Code | When |
|---|---|
| `library-playground-other-universe` | a playground prefix lies inside, or holds, a book folder of another entry |
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground prefix that holds another or is listed twice, a prefix equal to a book folder or holding its own entry's book, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
| `library-supporting-other-universe` | a supporting prefix lies inside, or holds, a book folder of another entry |
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground or supporting prefix that holds another of its list or is listed twice, a prefix equal to a book folder or holding its own entry's book, a supporting prefix that holds a playground prefix or overlaps another entry's, a bad `editor`, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
## Scope
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
mapped. It checks prefixes first. A path under any entry's playground prefix is `playground`, with that
entry as `universe` and the book folder holding the prefix as `book`, or `null` at universe level and
mapped. It checks prefixes first, supporting before playground. A path under any entry's supporting
prefix is `supporting`, and otherwise one under a playground prefix is `playground`. Either way that
entry is `universe` and the book folder holding the prefix is `book`, or `null` at universe level and
in a draft. Any other path under a book folder is `canon`. Everything else is `unmapped`. The type is
the first folder after the playground prefix, or after the book: a `types` name, `manuscript`, or
`null`.
the first folder after the prefix, or after the book: a `types` name, `manuscript`, or `null`.
## Agent write paths
`supportingPath(mapping, universe, path, tree)` returns `path` when an agent may write it in that
universe, else it refuses `library-write-story-path` (C2-01d, M12). `tree` is the array `listTree`
returns for the commit the write lands on; anything else is a TypeError. It reads no git. The checks
run in this order, so a path with two faults always names the same one:
1. a string of at most 1,024 UTF-8 bytes;
2. no control character (the `editor` class), and the message leaves the path out;
3. no backslash;
4. not absolute;
5. no empty, `.` or `..` segment, which also refuses `//` and a trailing `/`;
6. `classify` gives `supporting` in that same universe, which refuses unmapped, `.canvas`, `.base`
and attachment paths, canon, a playground-only folder and another universe's prefix;
7. no folder on the way, and not the path itself, is a symlink (`120000`) or gitlink (`160000`) in
`tree`;
8. no folder on the way, and not the path itself, matches a tree path or tree folder after
`normalize('NFC').toLowerCase()` unless it is that exact name. APFS folds case and Unicode form,
so her checkout would put the file somewhere else. A tree that already holds case twins refuses a
write to either (R7).
A delete's further rule, that the path exists at the tip as a `100644` blob, belongs to the C2-02
broker.
## Reader
-188
View File
@@ -1,188 +0,0 @@
// C2-06 (design 5.2, rulings after r11 Q222, Q223 and Q226): the citation guard. The broker runs it
// before it stores an agent's message to the human in a business with a library project. It reads
// only the citations, never what the text says. Codes are design 5.2's table.
import { git } from '../../project/src/git.mjs';
import { classify } from './scope.mjs';
import { listTree, readLibrary, readTexts, splitLines } from './reader.mjs';
import { LibraryError } from './problems.mjs';
import { mirrorPath, readSyncState } from './mirror.mjs';
import { plain } from './limits.mjs';
import { FIXED_TEXTS } from './reply-protocol.mjs';
export { GUARD_CODES, FIXED_TEXTS } from './reply-protocol.mjs';
// Web cites are for these definitions only (design 5.2).
export const WEB_CITE_DEFINITIONS = Object.freeze(['research', 'companion']);
// The reason reaches the runner (sage-3599): the guard's own text naming the check, the cite and the
// limit, never a quote or file text, and at most REASON_MAX characters.
export const REASON_MAX = 300;
export class GuardError extends LibraryError {
constructor(code, reason) { reason = reason.slice(0, REASON_MAX); super(code, reason); this.reason = reason; }
}
const refuse = (code, reason) => { throw new GuardError(code, reason); };
const SHA = /^[a-f0-9]{40}$/;
const LIBRARY_CITE = ['path', 'commit', 'lines', 'quote'];
const WEB_CITE = ['url', 'quote'];
const only = (value, required, optional = []) => plain(value)
&& required.every(k => Object.hasOwn(value, k)) && Object.keys(value).every(k => required.includes(k) || optional.includes(k));
const text = value => typeof value === 'string' && value.length > 0 && !value.includes('\0');
function libraryCite(c) {
return only(c, LIBRARY_CITE, ['as']) && text(c.path) && c.path.length <= 4096 && typeof c.commit === 'string' && SHA.test(c.commit)
&& Array.isArray(c.lines) && c.lines.length === 2 && c.lines.every(n => Number.isSafeInteger(n) && n >= 1) && c.lines[0] <= c.lines[1]
&& text(c.quote) && (!Object.hasOwn(c, 'as') || c.as === 'canon');
}
const webCite = c => only(c, WEB_CITE) && text(c.url) && c.url.length <= 4096 && text(c.quote);
// A body is free text, or a JSON object {text, citations} that must fit the schema. Returns
// { text, citations, cited } where `cited` says the body was the JSON form.
export function parseReply(body) {
let value;
try { value = JSON.parse(body); } catch { return { text: body, citations: [], cited: false }; }
if (!plain(value)) return { text: body, citations: [], cited: false };
if (!only(value, ['text', 'citations']) || typeof value.text !== 'string' || !Array.isArray(value.citations))
refuse('reply-invalid', 'a JSON object reply holds exactly "text", a string, and "citations", a list');
value.citations.forEach((c, i) => {
if (!libraryCite(c) && !webCite(c))
refuse('reply-invalid', `citation ${i + 1} is neither {path, commit, lines, quote, as?} nor {url, quote}`);
});
return { text: value.text, citations: value.citations, cited: true };
}
const squash = value => value.replace(/\s+/g, ' ').trim();
// Design 5.2's checks, in the table's order. `limits` comes from resolveReplyLimits. `library` reads
// git for a library cite (libraryReader below). `web` is { allowed, pages } for this sender: whether
// its definition may cite a url, and the session's kept fetches. Returns the stored check result,
// { citations: [{ scope, agent, file }] } in body order (agreed with row 225, morgana-0198 and
// morgana-0200; a url cite is { scope: 'web', agent: null }), or null for a
// plain body. Throws GuardError.
export async function checkReply(body, { limits, mapping, library, web }) {
if (Object.values(FIXED_TEXTS).includes(body)) return null;
const reply = parseReply(body);
const { citations } = reply;
if (limits.requireCitation && citations.length === 0)
refuse('citation-missing', 'replyLimits.requireCitation is true, so a reply carries at least one citation');
if (citations.length > limits.citationsPerReply)
refuse('citations-over-limit', `replyLimits.citationsPerReply is ${limits.citationsPerReply}; the reply carries ${citations.length}`);
if (!reply.cited) return null;
const found = [];
for (const [i, c] of citations.entries()) {
if (!Object.hasOwn(c, 'path')) { found.push(null); continue; }
const at = `citation ${i + 1} (${c.path})`;
const where = classify(mapping, c.path);
if (where.scope === 'unmapped') refuse('citation-invalid', `${at} names a path that isn't mapped`);
const lines = await library.lines(c.commit, c.path);
if (lines === null) refuse('citation-invalid', `${at} isn't an indexed file at a commit reachable from the served commit`);
if (c.lines[1] > lines.length) refuse('citation-invalid', `${at} cites lines past the file's ${lines.length}`);
const quote = squash(c.quote);
if (!quote || !squash(lines.slice(c.lines[0] - 1, c.lines[1]).join('\n')).includes(quote))
refuse('citation-invalid', `${at} quote isn't in lines ${c.lines[0]}-${c.lines[1]}`);
found.push(where);
}
citations.forEach((c, i) => {
if (c.as === 'canon' && found[i].scope !== 'canon')
refuse('playground-cited-as-canon', `citation ${i + 1} (${c.path}) is ${found[i].scope}, not canon`);
});
const universes = [...new Set(found.filter(Boolean).map(w => w.universe))];
if (universes.length > 1) {
const hybrid = universes.find(u => mapping.universes[u]?.kind === 'hybrid');
const sources = hybrid ? mapping.universes[hybrid].sources : [];
if (!hybrid || !universes.every(u => u === hybrid || sources.includes(u)))
refuse('citation-cross-universe', `the citations span universes ${universes.join(', ')}`);
}
citations.forEach((c, i) => {
if (!Object.hasOwn(c, 'url')) return;
if (!web.allowed) refuse('citation-unfetched', `citation ${i + 1}: only research and companion cite a url`);
const quote = squash(c.quote);
const pages = web.pages.filter(p => p.url === c.url || p.finalUrl === c.url);
if (!pages.length) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`);
if (!quote || !pages.some(page => squash(page.text).includes(quote))) refuse('citation-unfetched', `citation ${i + 1}: the quote isn't in the url's kept text`);
});
const out = [];
for (const [i, c] of citations.entries())
out.push(found[i] ? { scope: found[i].scope, ...await library.origin(c.commit, c.path) } : { scope: 'web', agent: null });
return { citations: out };
}
// Git reads for one check against the bare mirror. `lines` is the file's lines at `commit` when the
// commit is reachable from the served commit and readLibrary indexes the path there (sage-3595 item 4:
// readLibrary at the cited commit stands in for the C2-05a index), else null. Reads are cached per
// commit for the one check.
export function libraryReader(mirror, served, mapping) {
const indexed = new Map(), reach = new Map();
const once = (cache, key, read) => {
if (!cache.has(key)) cache.set(key, read().catch(() => null));
return cache.get(key);
};
const reachable = commit => once(reach, commit, async () => {
if (!mirror || !served) return false;
if (await git(mirror, ['cat-file', '-t', commit]) !== 'commit') return false;
await git(mirror, ['merge-base', '--is-ancestor', commit, served]);
return true;
});
const index = commit => once(indexed, commit, async () => {
const [read, entries] = await Promise.all([readLibrary({ repo: mirror, commit, mapping }), listTree(mirror, commit)]);
return { files: new Set(read.files.map(file => file.path)), entries };
});
return {
async lines(commit, path) {
if (!await reachable(commit)) return null;
const at = await index(commit);
if (!at?.files.has(path)) return null;
const texts = await readTexts(mirror, [at.entries.find(entry => entry.path === path)]).catch(() => new Map());
return texts.has(path) ? splitLines(texts.get(path)) : null;
},
// One git call: the file's last commit at `commit` (morgana-0200: the Console compares it with the
// served tip's, as the cited library commit needn't touch the file) and that commit's trailer.
async origin(commit, path) {
if (!mirror) return { agent: null, file: null };
try {
const [file, trailer = ''] = (await git(mirror, ['--literal-pathspecs', 'log', '-1', '--format=%H%x00%(trailers:key=Mosaic-Agent,valueonly)', commit, '--', path])).split('\0');
return { agent: trailer.trim() || null, file: SHA.test(file) ? file : null };
} catch { return { agent: null, file: null }; }
},
// The file's last commit at the served tip, for thread.messages (sage-3595), or null.
async last(path) {
if (!mirror || !served) return null;
try {
const value = await git(mirror, ['--literal-pathspecs', 'log', '-1', '--format=%H', served, '--', path]);
return SHA.test(value) ? value : null;
} catch { return null; }
},
};
}
// thread.messages' `last` (sage-3595): each path's last commit at the served tip, one git call per
// distinct path. A path maps to null when there is no served commit or the read fails.
export async function lastCommits(context, paths) {
const { mirror, served } = await servedMirror(context);
const reader = libraryReader(mirror, served, null);
const out = new Map();
for (const path of new Set(paths)) out.set(path, await reader.last(path));
return out;
}
// The library project's bare mirror and its served commit. Either is null when there is none yet, so
// a plain reply still passes and any library cite refuses citation-invalid.
async function servedMirror(context) {
const mapping = context.business.projects[context.project];
let mirror = null, served = null;
try {
mirror = mirrorPath(context);
served = (await readSyncState(mirror, { branch: mapping.branch })).served ?? null;
} catch { served = null; }
return { mapping, mirror, served };
}
// The broker's entry: the library project's mirror at its served commit, then checkReply. `context`
// is { business, project, dataRoot }. `definition` is the sender's role definition, which decides only
// whether a url cite may pass; `pages` is the broker's page store for the sender's session.
export async function guardReply(context, body, { limits, definition, pages }) {
const { mapping, mirror, served } = await servedMirror(context);
const web = { allowed: WEB_CITE_DEFINITIONS.includes(definition), pages };
return checkReply(body, { limits, mapping, library: libraryReader(mirror, served, mapping), web });
}
+75
View File
@@ -0,0 +1,75 @@
// The lookup index of design 3 (C2-05a): types, names, links and backlinks for one commit of her repo.
// It is built from the reader's output, so it holds only mapped, readable files: R1, R2 and R7 leave a
// file out, and an unresolved link is not in it. It is cached in memory by commit id. The key also
// carries the repo path and a digest of the mapping, because one commit reads differently under another
// mapping. The index is not universe-aware; query.mjs decides what a universe may see.
import { createHash } from 'node:crypto';
import { readLibrary } from './reader.mjs';
import { raise } from './problems.mjs';
const CACHE_LIMIT = 8;
const cache = new Map();
const push = (map, key, value) => {
if (!map.has(key)) map.set(key, []);
map.get(key).push(value);
};
// A file's name for lookups: its front matter `name` when it is text, else its file name.
const displayName = file => {
const name = file.frontmatter?.name;
return typeof name === 'string' && name.trim() ? name.trim() : file.name;
};
// A link as the index keeps it: the file at the other end, its line, and the field when front matter holds it.
const linkAt = (path, link) => ({ path, line: link.line, ...(link.field === undefined ? {} : { field: link.field }) });
// Builds the index from readLibrary's output. Pure: no git, no clock.
export function buildIndex(library, mapping) {
const files = new Map(), types = new Map(), names = new Map(), linksOut = new Map(), linksIn = new Map();
for (const file of library.files) {
const name = displayName(file);
files.set(file.path, {
path: file.path, scope: file.scope, universe: file.universe, book: file.book, type: file.type,
folder: file.folder, stem: file.name, name, frontmatter: file.frontmatter, lines: file.lines,
});
if (file.type !== null) push(types, file.type, file.path);
push(names, name.toLowerCase(), file.path);
linksOut.set(file.path, file.links.map(link => linkAt(link.path, link)));
for (const link of file.links) push(linksIn, link.path, linkAt(file.path, link));
}
return Object.freeze({
commit: library.commit, mapping, files, paths: [...files.keys()], types, names, linksOut, linksIn,
problems: library.problems,
});
}
const digest = mapping => createHash('sha256').update(JSON.stringify(mapping)).digest('hex');
// The index for one commit under one mapping, read once and kept in memory. A repeat call returns the same
// object. Past CACHE_LIMIT entries the oldest goes. A failed read is not kept.
export async function indexFor({ repo, commit, mapping }) {
if (!mapping || mapping.kind !== 'library') raise('library-mapping-invalid', 'expected a checked library mapping');
const key = JSON.stringify([repo, commit, digest(mapping)]);
if (cache.has(key)) {
const hit = cache.get(key);
cache.delete(key);
cache.set(key, hit);
return hit;
}
const pending = readLibrary({ repo, commit, mapping }).then(library => buildIndex(library, mapping));
cache.set(key, pending);
while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
try {
return await pending;
} catch (error) {
if (cache.get(key) === pending) cache.delete(key);
throw error;
}
}
export function clearIndexCache() {
cache.clear();
}
export function indexCacheSize() {
return cache.size;
}
+3 -2
View File
@@ -2,11 +2,12 @@
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
export { checkMapping, TYPES } from './mapping.mjs';
export { classify } from './scope.mjs';
export { supportingPath } from './supporting.mjs';
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
export { survey, formatSurvey, surveyMain } from './survey.mjs';
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs';
export { GUARD_CODES, FIXED_TEXTS, WEB_CITE_DEFINITIONS, REASON_MAX, GuardError, parseReply, checkReply, libraryReader, guardReply, lastCommits } from './guard.mjs';
export { buildIndex, indexFor, clearIndexCache, indexCacheSize } from './index-build.mjs';
export { sees, find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from './query.mjs';
-41
View File
@@ -1,41 +0,0 @@
// C2-06 (design 5.2): the business file's replyLimits, read by the citation guard and written only
// by the human's library.limits.set. Pure, so the business package's organization writer imports it.
export const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
// The replyLimits keys, their defaults and their valid values. A key is read only when Object.hasOwn
// finds it and its value passes `valid`; anything else is the default (webby-0144 R3).
export const REPLY_LIMITS = Object.freeze({
requireCitation: { default: false, valid: v => typeof v === 'boolean' },
citationsPerReply: { default: 20, valid: v => Number.isInteger(v) && v >= 1 && v <= 1000 },
});
// `read` is { readable: false } when the business file is missing, unreadable, not JSON or not an
// object at check time, else { readable: true, document }. An unreadable file checks at the defaults
// except requireCitation, which is true so a setting she turned on doesn't fail open (webby-0151 R2).
export function resolveReplyLimits(read) {
const limits = {}, fallback = [];
const unreadable = !read?.readable || !plain(read.document);
const raw = unreadable ? undefined : read.document.replyLimits;
for (const [key, { default: value, valid }] of Object.entries(REPLY_LIMITS)) {
if (plain(raw) && Object.hasOwn(raw, key) && valid(raw[key])) limits[key] = raw[key];
else {
limits[key] = value;
// A key fell back when replyLimits is there but isn't an object, or holds the key with a value
// outside the table. A key left out is simply its default.
if (raw !== undefined && (!plain(raw) || Object.hasOwn(raw, key))) fallback.push(key);
}
}
if (unreadable) limits.requireCitation = true;
return { ...limits, fallback, unreadable };
}
// library.limits.set's check: the whole object is replaced, a key left out means its default, and an
// unknown key or a value outside the table refuses. Returns the object to write.
export function checkReplyLimits(value) {
if (!plain(value)) return null;
for (const [key, v] of Object.entries(value))
if (!Object.hasOwn(REPLY_LIMITS, key) || !REPLY_LIMITS[key].valid(v)) return null;
return structuredClone(value);
}
+64 -20
View File
@@ -1,8 +1,9 @@
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
// books, type folders, manuscript folders and playground prefixes at three levels (ruling after r11,
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. A pure validator. Business file
// loading wires it in C2-04, which passes its own `fail` so the refusal reads as a business file
// problem. Agents can't write this entry (M9, C2-04).
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. Supporting
// prefixes (C2-01d) follow the same rules and are where agents may write (M12). A pure validator.
// Business file loading wires it in C2-04, which passes its own `fail` so the refusal reads as a
// business file problem. Agents can't write this entry (M9, C2-04).
import { raise } from './problems.mjs';
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
@@ -12,6 +13,12 @@ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
const USER = /^[A-Za-z0-9_.-]{1,100}$/;
const CONSTRAINT_KEYS = ['exclude', 'pin'];
const KINDS = ['hybrid', 'draft'];
// C2-01d (F41): the editor ident her Console saves commit under. Git drops `<` and `>` from an ident
// and commits under a different name, so they refuse here (webby-0169 R1).
const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;
// Git's ident.c crud() strips any byte <= 0x20 and , : ; < > " \ ' from both ends of an ident, so a
// value that starts or ends with one would save one name and commit another (sage-3601, sage-3606).
const IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/;
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
// and dot names are not.
@@ -40,7 +47,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
};
object(entry, '');
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'universes', 'types', 'manuscript'], 'entry');
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'editor', 'universes', 'types', 'manuscript'], 'entry');
if (entry.kind !== 'library') bad('kind must be "library"');
let url;
try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); }
@@ -52,6 +59,20 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
if (Object.hasOwn(entry, 'user') && (typeof entry.user !== 'string' || !USER.test(entry.user)))
bad('user must be a plain account name');
if (Object.hasOwn(entry, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name');
if (Object.hasOwn(entry, 'editor')) {
object(entry.editor, 'editor');
only(entry.editor, ['name', 'email', 'user'], 'editor');
// Her Gitea login for the library-human slot, which C2-02 passes in (sage-2925). Optional here.
if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' || !USER.test(entry.editor.user)))
bad('editor.user must be a plain account name');
for (const key of ['name', 'email']) {
const value = entry.editor[key];
if (typeof value !== 'string' || !value.trim() || IDENT_BAD.test(value))
bad(`editor.${key} must be non-empty text with no control character, < or >`);
if (value.trim() !== value || IDENT_CRUD_END.test(value))
bad(`editor.${key} must not start or end with whitespace or any of , : ; " ' \\`);
}
}
object(entry.types, 'types');
for (const [name, type] of Object.entries(entry.types)) {
@@ -64,6 +85,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
object(entry.universes, 'universes');
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
const supports = [], supportOwner = new Map();
for (const [id, universe] of Object.entries(entry.universes)) {
const name = `universes.${id}`;
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
@@ -71,8 +93,8 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
if (Object.hasOwn(universe, 'kind') && !KINDS.includes(universe.kind)) bad(`${name}.kind must be "hybrid" or "draft"`);
const hybrid = universe.kind === 'hybrid', draft = universe.kind === 'draft';
if (draft && Object.hasOwn(universe, 'books')) bad(`${name} is a draft and takes no books`);
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes']
: draft ? ['kind', 'name', 'playground'] : ['name', 'books', 'playground'], name);
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'supporting', 'constraints', 'notes']
: draft ? ['kind', 'name', 'playground', 'supporting'] : ['name', 'books', 'playground', 'supporting'], name);
if (typeof universe.name !== 'string' || !universe.name.trim() || universe.name.length > 128 || /[\u0000-\u001f]/.test(universe.name))
bad(`${name}.name must be a short display name`);
const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path');
@@ -81,12 +103,14 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
bookOwner.set(book, id);
}
const playground = list(universe.playground ?? [], `${name}.playground`,
value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)), 'a folder path ending in /');
const prefix = value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1));
const playground = list(universe.playground ?? [], `${name}.playground`, prefix, 'a folder path ending in /');
if (draft && !playground.length) bad(`${name}.playground must name at least one prefix`);
for (const prefix of playground) { prefixes.push(prefix); prefixOwner.set(prefix, id); }
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground }; continue; }
const out = { name: universe.name, books, playground };
for (const p of playground) { prefixes.push(p); prefixOwner.set(p, id); }
const supporting = list(universe.supporting ?? [], `${name}.supporting`, prefix, 'a folder path ending in /');
for (const p of supporting) { supports.push(p); supportOwner.set(p, id); }
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground, supporting }; continue; }
const out = { name: universe.name, books, playground, supporting };
if (hybrid) {
out.kind = 'hybrid';
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
@@ -124,18 +148,36 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`);
for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`);
if (new Set(prefixes).size !== prefixes.length) bad('a playground prefix is listed twice');
for (const a of supports) for (const b of supports) if (a !== b && holds(a, b)) bad(`supporting ${JSON.stringify(a)} holds supporting ${JSON.stringify(b)}`);
if (new Set(supports).size !== supports.length) bad('a supporting prefix is listed twice');
// A prefix's level comes from where it sits (ruling after r11). Inside one of its own entry's books
// is book level, outside every book is universe level, and a draft's prefixes are all outside. Scope
// checks prefixes first (scope.mjs), so a prefix equal to or holding a book would turn that book
// into playground.
for (const [prefix, id] of prefixOwner) {
for (const [book, owner] of bookOwner) {
const at = `universes.${id}.playground ${JSON.stringify(prefix)}`;
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
const inside = holds(book, prefix), holding = holds(prefix, book);
if ((inside || holding) && owner !== id)
fail('library-playground-other-universe', `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
// into playground or supporting.
const againstBooks = (owners, list, code) => {
for (const [prefix, id] of owners) {
for (const [book, owner] of bookOwner) {
const at = `universes.${id}.${list} ${JSON.stringify(prefix)}`;
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
const inside = holds(book, prefix), holding = holds(prefix, book);
if ((inside || holding) && owner !== id)
fail(code, `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
}
}
};
againstBooks(prefixOwner, 'playground', 'library-playground-other-universe');
againstBooks(supportOwner, 'supporting', 'library-supporting-other-universe');
// C2-01d, between the lists. A supporting prefix may equal a playground prefix of its own entry, and
// a playground may hold one of its own entry's supporting prefixes. A supporting prefix that holds a
// playground would hide it, since scope checks supporting first. Any overlap across entries would
// give one path two universes.
for (const [s, sid] of supportOwner) {
for (const [p, pid] of prefixOwner) {
const at = `universes.${sid}.supporting ${JSON.stringify(s)}`;
if (sid !== pid && (s === p || holds(s, p) || holds(p, s)))
bad(`${at} overlaps playground ${JSON.stringify(p)} of universes.${pid}`);
if (sid === pid && s !== p && holds(s, p)) bad(`${at} holds playground ${JSON.stringify(p)}`);
}
}
@@ -143,6 +185,8 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
kind: 'library', remote: entry.remote, branch: entry.branch,
...(Object.hasOwn(entry, 'user') ? { user: entry.user } : {}),
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
...(Object.hasOwn(entry, 'editor') ? { editor: { name: entry.editor.name, email: entry.editor.email,
...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {}) } } : {}),
universes, types: { ...entry.types }, manuscript,
});
}
+250
View File
@@ -0,0 +1,250 @@
// The lookup queries of design 3 (C2-05a): find, get, search, manuscript and universeInfo. Each takes a
// source {repo, commit, mapping} and an argument object, and returns a reply that fits QUERY_REPLY_CAP.
// Universe isolation is here, in sees(): a file reaches a reply only when sees() allows it. Scope comes
// from the path (D2). Unmapped files are not in the index, so they never appear. Lists page by position
// and never cut an item. A search line too long to fit alone comes back as a marked hit with no excerpt;
// a find item, a file's front matter or one of its lines that cannot fit alone is refused.
import { raise } from './problems.mjs';
import { TYPES } from './mapping.mjs';
import { indexFor } from './index-build.mjs';
import { listTree } from './reader.mjs';
// The same cap as packages/harness/src/tools.mjs REPLY_CAP. The query tests assert the two stay equal.
export const QUERY_REPLY_CAP = 40_000;
export const LOOKUP_LIMIT = 50;
export const MANUSCRIPT_LINES = 400;
const SCOPES = Object.freeze(['canon', 'playground', 'supporting']);
const MANUSCRIPT_SCOPES = Object.freeze(['canon', 'playground']);
const SCOPE_RANK = Object.freeze({ supporting: 0, playground: 1, canon: 2 });
const bytes = value => JSON.stringify(value).length;
const invalid = message => raise('query-argument-invalid', message);
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
function textArg(value, name) {
if (typeof value !== 'string') invalid(`${name} must be text`);
return value;
}
function scopeArg(value, allowed = SCOPES) {
if (value === undefined) return 'canon';
if (!allowed.includes(value)) invalid(`scope must be one of ${allowed.join(", ")}`);
return value;
}
function positionArg(value, name, fallback) {
if (value === undefined) return fallback;
if (!Number.isSafeInteger(value) || value < 1) invalid(`${name} must be a line number of 1 or more`);
return value;
}
function offsetArg(value) {
if (value === undefined) return 0;
if (!Number.isSafeInteger(value) || value < 0) invalid('offset must be a whole number of 0 or more');
return value;
}
function universeArg(mapping, value) {
if (typeof value !== 'string' || !value) invalid('universe is required');
if (!Object.hasOwn(mapping.universes, value)) raise('query-universe-unknown', `this library has no universe ${JSON.stringify(value)}`);
return value;
}
// Whether universe U may see a file. Its own files, in either scope. A hybrid also sees the canon of
// its declared sources, minus the folders its exclude constraints name. A hybrid never sees a source's
// playground. Pin constraints do not hide anything; they are reported by universeInfo only.
export function sees(mapping, universe, file) {
if (file.universe === universe) return true;
const entry = mapping.universes[universe];
if (entry.kind !== 'hybrid' || !entry.sources.includes(file.universe) || file.scope !== 'canon') return false;
return !entry.constraints.some(c => c.exclude !== undefined && inside(c.exclude, file.path));
}
// The files one universe may see in one scope, with optional book and type filters, in path order.
async function visible(source, universe, { scope, book, type }) {
const index = await indexFor(source);
const files = index.paths
.map(path => index.files.get(path))
.filter(file => file.scope === scope && sees(index.mapping, universe, file)
&& (book === undefined || file.book === book) && (type === undefined || file.type === type));
return { index, files };
}
// A reply whose size is over the cap is refused, never cut.
function checked(reply) {
if (bytes(reply) > QUERY_REPLY_CAP) raise('query-item-too-large', `the reply is over the ${QUERY_REPLY_CAP}-character cap`);
return reply;
}
// Picks items from `offset`, at most `limit`, while the reply still fits. build(picked, next) returns
// the reply; `next` is the position of the first item not picked, or null when none is left. An item
// that cannot fit even alone is refused: skipping it would hide it, and cutting it would break it.
function fitPage(items, { offset = 0, limit = LOOKUP_LIMIT, build }) {
const picked = [];
let at = offset;
while (at < items.length && picked.length < limit) {
// The size check uses items.length for `next`, the widest number a cursor can take, so a page never runs over.
if (bytes(build([...picked, items[at]], items.length)) > QUERY_REPLY_CAP) {
if (!picked.length) raise('query-item-too-large', `the item at position ${at} alone is over the ${QUERY_REPLY_CAP}-character cap`);
break;
}
picked.push(items[at]);
at += 1;
}
return checked(build(picked, at < items.length ? at : null));
}
// find: the files whose name or file name holds `query`, case-insensitive. Up to 50 per page.
export async function find(source, { universe: name, query, type, book, scope, offset } = {}) {
const universe = universeArg(source.mapping, name);
const needle = textArg(query, 'query').toLowerCase();
if (type !== undefined && !TYPES.includes(type)) invalid(`type must be one of ${TYPES.join(', ')}`);
const { index, files } = await visible(source, universe, {
scope: scopeArg(scope), book: book === undefined ? undefined : textArg(book, 'book'), type,
});
const hits = files.filter(file => file.name.toLowerCase().includes(needle) || file.stem.toLowerCase().includes(needle));
return fitPage(hits, {
offset: offsetArg(offset),
build: (picked, next) => ({
commit: index.commit,
results: picked.map(file => ({
path: file.path, type: file.type, name: file.name, appears_in: file.frontmatter?.appears_in ?? null,
scope: file.scope, universe: file.universe, book: file.book,
})),
next,
}),
});
}
// get: one file in the requested scope (canon default), its served-tree blob, front matter,
// lines and visible links both ways. Links keep all visible scopes, ordered supporting/playground/canon.
// Returns null when the file is missing or not visible, so a caller cannot tell the two apart. Lines page
// by `from` (reply `next`), links page together by `links_from` (reply `links_next`); `links_out` come
// before `links_in`. Every page carries the front matter. Links take at most half the cap, so a hub note
// with many backlinks stays readable and still leaves room for lines. A page with lines left carries at
// least one line, so links shrink when a line needs the room; read on with `from: next` and
// `links_from: links_next`. A `from` past the last line returns links alone. Only front matter, or a
// line that cannot fit with no links beside it, is refused.
export async function get(source, { universe: name, path, scope, from, links_from: linksFrom } = {}) {
const universe = universeArg(source.mapping, name);
const requestedScope = scopeArg(scope);
const startLine = positionArg(from, 'from', 1);
const linkStart = offsetArg(linksFrom);
if (typeof path !== 'string') invalid('path must be text');
const index = await indexFor(source);
const file = index.files.get(path);
if (!file || file.scope !== requestedScope || !sees(index.mapping, universe, file)) return null;
const reachable = links => links.filter(link => {
const other = index.files.get(link.path);
return other !== undefined && sees(index.mapping, universe, other);
}).sort((a, b) => SCOPE_RANK[index.files.get(a.path).scope] - SCOPE_RANK[index.files.get(b.path).scope]);
const outs = reachable(index.linksOut.get(file.path) ?? []);
const ins = reachable(index.linksIn.get(file.path) ?? []);
const links = [...outs.map(link => ({ out: true, link })), ...ins.map(link => ({ out: false, link }))];
// The reader/index does not keep tree oids. Resolve only in this immutable served
// snapshot, never HEAD; listTree already checks/bounds the tree and its object IDs.
const entry = (await listTree(source.repo, index.commit)).find(entry => entry.path === file.path);
if (!entry || entry.mode !== '100644' || entry.type !== 'blob') raise('library-commit', 'indexed file is absent from the served tree');
const blob = entry.oid;
const lines = file.lines.slice(startLine - 1);
const linkRest = Math.max(0, links.length - linkStart);
const page = (nLines, nLinks) => {
const taken = links.slice(linkStart, linkStart + nLinks);
return {
path: file.path, commit: index.commit, blob, scope: file.scope, frontmatter: file.frontmatter,
lines: lines.slice(0, nLines),
links_out: taken.filter(item => item.out).map(item => item.link),
links_in: taken.filter(item => !item.out).map(item => item.link),
next: nLines < lines.length ? startLine + nLines : null,
links_next: nLinks < linkRest ? linkStart + nLinks : null,
};
};
// How many leading items fit in `room`, by each item's own size.
const count = (items, room) => {
let used = 0, n = 0;
while (n < items.length && used + bytes(items[n]) + 1 <= room) used += bytes(items[n++]) + 1;
return n;
};
const base = bytes(page(0, 0));
if (base > QUERY_REPLY_CAP) raise('query-item-too-large', `the front matter of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
if (lines.length && bytes(page(1, 0)) > QUERY_REPLY_CAP) {
raise('query-item-too-large', `line ${lines[0][0]} of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
}
const rest = links.slice(linkStart);
let nLinks = count(rest, (lines.length ? Math.floor(QUERY_REPLY_CAP / 2) : QUERY_REPLY_CAP) - base);
if (!nLinks && rest.length) nLinks = count(rest, QUERY_REPLY_CAP - base);
if (!nLinks && rest.length) raise('query-item-too-large', `a link of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
while (lines.length && nLinks > 0 && bytes(page(1, nLinks)) > QUERY_REPLY_CAP) nLinks = Math.floor(nLinks / 2);
let nLines = Math.max(lines.length ? 1 : 0, count(lines, QUERY_REPLY_CAP - bytes(page(0, nLinks))));
while (nLines > 1 && bytes(page(nLines, nLinks)) > QUERY_REPLY_CAP) nLines -= 1;
return checked(page(nLines, nLinks));
}
// search: lines whose text holds `text`, case-insensitive. The excerpt is the whole line. Up to 50 per page.
export async function search(source, { universe: name, text, book, scope, offset } = {}) {
const universe = universeArg(source.mapping, name);
const needle = textArg(text, 'text').toLowerCase();
if (!needle) invalid('text must not be empty');
const { index, files } = await visible(source, universe, {
scope: scopeArg(scope), book: book === undefined ? undefined : textArg(book, 'book'),
});
const hits = [];
for (const file of files) {
for (const [line, body] of file.lines) {
if (!body.toLowerCase().includes(needle)) continue;
const hit = { path: file.path, line, excerpt: body, scope: file.scope };
// The excerpt is the whole line. A line too long to fit alone is marked, not cut and not refused.
hits.push(bytes({ commit: index.commit, results: [hit], next: Number.MAX_SAFE_INTEGER }) > QUERY_REPLY_CAP
? { path: file.path, line, excerpt: null, excerpt_too_long: true, scope: file.scope } : hit);
}
}
return fitPage(hits, {
offset: offsetArg(offset),
build: (picked, next) => ({ commit: index.commit, results: picked, next }),
});
}
// manuscript: one chapter file of a book, by its file name, up to 400 lines per call from `from` to `to`.
// Without a chapter it returns the book's first chapter file in path order. Every reply carries
// `next_chapter`, the next chapter file's name in path order or null. A name that matches two visible
// files is refused. Files outside every book (book null) are never chapters of a book.
export async function manuscript(source, { universe: name, book, scope, chapter, from, to } = {}) {
const universe = universeArg(source.mapping, name);
const bookName = textArg(book, 'book');
const chapterName = chapter === undefined ? undefined : textArg(chapter, 'chapter');
const start = positionArg(from, 'from', 1);
const end = positionArg(to, 'to', Infinity);
if (end < start) invalid('to must not be before from');
const { index, files } = await visible(source, universe, { scope: scopeArg(scope, MANUSCRIPT_SCOPES), book: bookName, type: 'manuscript' });
let at = 0;
if (chapterName !== undefined) {
const matches = files.filter(file => file.stem === chapterName);
if (matches.length > 1) raise('query-chapter-ambiguous', `${matches.length} visible files are named ${JSON.stringify(chapterName)}`);
if (!matches.length) return null;
at = files.indexOf(matches[0]);
}
const file = files[at];
if (!file) return null;
const nextChapter = files[at + 1]?.stem ?? null;
return fitPage(file.lines.slice(start - 1, end), {
limit: MANUSCRIPT_LINES,
build: (picked, next) => ({
path: file.path, commit: index.commit, scope: file.scope, lines: picked,
next: next === null ? null : start + next, next_chapter: nextChapter,
}),
});
}
// universeInfo: a universe's kind, name, books, playground prefixes, type folders and manuscript folders.
// A hybrid adds its sources, constraints and notes. A draft has no books and no canon: its kind, name
// and prefixes are the whole answer beside the mapping's types. It reads only the checked mapping and the
// served commit.
export async function universeInfo(source, { universe: name } = {}) {
const universe = universeArg(source.mapping, name);
const entry = source.mapping.universes[universe];
const hybrid = entry.kind === 'hybrid';
return checked({
commit: source.commit, universe, name: entry.name, kind: entry.kind ?? 'universe',
books: entry.books, playground: entry.playground, types: source.mapping.types, manuscript: source.mapping.manuscript,
...(hybrid ? {
sources: entry.sources, constraints: entry.constraints,
...(entry.notes === undefined ? {} : { notes: entry.notes }),
} : {}),
});
}
-12
View File
@@ -1,12 +0,0 @@
// C2-06: shared pure protocol constants for the host guard and sandboxed runner.
export const GUARD_CODES = Object.freeze(['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid',
'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']);
// The runner's four fixed texts in a library business. The guard accepts exactly these bodies when
// requireCitation is true; an agent that sends one gains nothing it couldn't say anyway.
export const FIXED_TEXTS = Object.freeze({
empty: '(the session gave no answer)',
failed: '(the turn failed)',
uncited: '(no answer passed the citation check)',
tooLong: '(the answer was too long to send)',
});
+12 -6
View File
@@ -2,16 +2,22 @@
// Paths are repo-relative and compared exactly, case included (R7 handles case-only twins).
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
// { scope: 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
// Prefixes come first (ruling after r11, C2-01c): a `.md` file under any entry's playground prefix is
// playground, with that entry as `universe` and the book folder that holds the prefix as `book`, or
// null at universe level and in a draft. Otherwise a `.md` file in a book folder is canon. `type` is
// the mapped type of the first folder under the prefix (playground) or under the book (canon),
// { scope: 'supporting' | 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
// Prefixes come first (ruling after r11, C2-01c), supporting before playground (C2-01d, design 1.2):
// a `.md` file under any entry's supporting prefix is supporting, and otherwise one under a playground
// prefix is playground. Either way `universe` is that entry and `book` is the book folder that holds
// the prefix, or null at universe level and in a draft. Otherwise a `.md` file in a book folder is
// canon. `type` is the mapped type of the first folder under the prefix or under the book (canon),
// 'manuscript' for a manuscript folder, else null. `folder` is that folder name. checkMapping keeps
// prefixes from overlapping, so at most one matches.
// prefixes of one list from overlapping, and keeps a supporting prefix from holding a playground
// prefix, so the first match is the only one that counts.
export function classify(mapping, path) {
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
const entries = Object.entries(mapping.universes);
for (const [universe, entry] of entries) {
const prefix = entry.supporting.find(p => inside(p, path));
if (prefix) return place(mapping, path, prefix, { scope: 'supporting', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
}
for (const [universe, entry] of entries) {
const prefix = entry.playground.find(p => inside(p, path));
if (prefix) return place(mapping, path, prefix, { scope: 'playground', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
+49
View File
@@ -0,0 +1,49 @@
// C2-01d (M12): the paths an agent may write. A path passes only under a supporting prefix of the
// named universe, and only if nothing in the tree could turn the write into something else: a
// symlink or gitlink on the way, or a name that differs from a tree name only in case or Unicode
// form (APFS folds both, so her checkout would put the file somewhere else). Pure; reads no git.
import { LibraryError } from './problems.mjs';
import { classify } from './scope.mjs';
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
const MAX_BYTES = 1024;
const LINKS = new Set(['120000', '160000']);
const fold = name => name.normalize('NFC').toLowerCase();
// `tree` is the array listTree returns ({ mode, type, oid, size, path }). Returns `path` or throws
// library-write-story-path naming the class. Checks run in a fixed order, so a path with two faults
// always names the same one.
export function supportingPath(mapping, universe, path, tree) {
if (!Array.isArray(tree)) throw new TypeError('supportingPath needs the listTree array');
const refuse = message => { throw new LibraryError('library-write-story-path', message); };
if (typeof path !== 'string') refuse('the path must be a string');
const at = JSON.stringify(path);
if (Buffer.byteLength(path, 'utf8') > MAX_BYTES) refuse(`${at} is longer than ${MAX_BYTES} bytes`);
if (CONTROL.test(path)) refuse('the path holds a control character');
if (path.includes('\\')) refuse(`${at} holds a backslash`);
if (path.startsWith('/')) refuse(`${at} is absolute`);
const parts = path.split('/');
if (parts.some(part => part === '' || part === '.' || part === '..')) refuse(`${at} has an empty, . or .. segment`);
const place = classify(mapping, path);
if (place.scope !== 'supporting' || place.universe !== universe)
refuse(`${at} is not under a supporting folder of universe ${JSON.stringify(universe)}`);
const own = parts.map((_, i) => parts.slice(0, i + 1).join('/'));
const modes = new Map(tree.map(entry => [entry.path, entry.mode]));
for (const p of own) if (LINKS.has(modes.get(p))) refuse(`${at} goes through the symlink or gitlink ${JSON.stringify(p)}`);
const names = new Map();
for (const entry of tree) {
const segs = entry.path.split('/');
for (let i = 1; i <= segs.length; i++) {
const name = segs.slice(0, i).join('/');
const key = fold(name);
if (!names.has(key)) names.set(key, new Set());
names.get(key).add(name);
}
}
for (const p of own) {
const seen = names.get(fold(p));
if (seen && [...seen].some(name => name !== p))
refuse(`${at} differs only in case or Unicode form from ${JSON.stringify([...seen].find(name => name !== p))}`);
}
return path;
}
+2 -2
View File
@@ -62,7 +62,7 @@ export async function survey({ repo, commit, mapping }) {
forms, attachments, fields,
};
if (mapping) {
const scopes = { canon: 0, playground: 0, unmapped: 0 };
const scopes = { canon: 0, playground: 0, supporting: 0, unmapped: 0 };
for (const entry of entries) scopes[classify(mapping, entry.path).scope]++;
const library = await readLibrary({ repo, commit: head, mapping });
const steps = Object.fromEntries(STEPS.map(s => [s, 0]));
@@ -89,7 +89,7 @@ export function formatSurvey(s) {
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}; attachment ${s.attachments}`);
for (const [field, c] of Object.entries(s.fields)) out.push(`field ${field} wikilink ${c.wikilink}, name ${c.name}`);
if (s.scopes) {
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, supporting ${s.scopes.supporting}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
out.push(`resolved ${STEPS.map(st => `${st} ${s.steps[st]}`).join(', ')}, unresolved ${s.steps.unresolved}`);
out.push(`problems ${Object.entries(s.problems).map(([code, n]) => `${code} ${n}`).join(', ')}`);
}
-129
View File
@@ -1,129 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { checkReply, GuardError, libraryReader, parseReply, FIXED_TEXTS } from '../src/guard.mjs';
import { resolveReplyLimits, checkReplyLimits } from '../src/limits.mjs';
import { BASE, MARKER, mapping, repo, git } from './helpers.mjs';
const defaults = resolveReplyLimits({ readable: true, document: {} });
const body = citations => JSON.stringify({ text: 'A fixture answer.', citations });
const cite = (commit, extra = {}) => ({ path: 'Book One/Characters/Mara Vell.md', commit, lines: [9, 9], quote: 'Grey eyes.', ...extra });
const context = r => ({ limits: defaults, mapping: mapping(), library: libraryReader(r.repo, r.commit, mapping()), web: { allowed: true, pages: [] } });
const refusal = code => e => e instanceof GuardError && e.code === code && typeof e.reason === 'string' && e.reason.length <= 300;
test('C2-06 reply schema: free bodies, exact objects and safe refusal reasons', async t => {
const c = context(repo(t));
for (const text of ['plain', 'null', '42', '[]', '"text"', '{broken']) {
assert.equal(parseReply(text).cited, false);
assert.equal(await checkReply(text, c), null);
}
assert.deepEqual(await checkReply(body([]), c), { citations: [] });
for (const value of [{}, { text: 'x', citations: [], scope: 'canon' }, { text: 1, citations: [] },
{ text: 'x', citations: {} }, { text: 'x', citations: [{ url: 'https://invalid.test', quote: 'x', agent: 'forged' }] },
{ text: 'x', citations: [cite('a'.repeat(40), { as: 'supporting' })] }])
await assert.rejects(checkReply(JSON.stringify(value), c), refusal('reply-invalid'));
});
test('C2-06 citation setting and all four exact fixed texts', async t => {
const c = context(repo(t)); c.limits = { ...defaults, requireCitation: true };
await assert.rejects(checkReply('plain', c), refusal('citation-missing'));
await assert.rejects(checkReply(body([]), c), refusal('citation-missing'));
for (const text of Object.values(FIXED_TEXTS)) {
assert.equal(await checkReply(text, c), null);
await assert.rejects(checkReply(text + ' ', c), refusal('citation-missing'));
}
});
test('C2-06 default citation cap passes 20 and refuses 21, even with optional citations', async t => {
const r = repo(t), c = context(r);
assert.equal((await checkReply(body(Array(20).fill(cite(r.commit))), c)).citations.length, 20);
await assert.rejects(checkReply(body(Array(21).fill(cite(r.commit))), c), refusal('citations-over-limit'));
});
test('C2-06 real Git citation scope, reachability, lines and normalized quotes', async t => {
const r = repo(t), c = context(r);
assert.equal((await checkReply(body([cite(r.commit, { quote: 'Grey\n eyes.' })]), c)).citations[0].scope, 'canon');
for (const extra of [{ path: 'Inbox Note.md', lines: [1, 1], quote: MARKER },
{ path: 'Book One/Board.canvas' }, { path: 'Book One/Characters/Missing.md' },
{ commit: '0'.repeat(40) }, { commit: git(r.repo, 'rev-parse', 'HEAD^{tree}') },
{ lines: [999, 999] }, { quote: 'not in her fixture' }])
await assert.rejects(checkReply(body([cite(r.commit, extra)]), c), refusal('citation-invalid'));
// A future commit exists locally but is not reachable from the served commit.
writeFileSync(join(r.repo, 'Inbox Note.md'), 'new\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'future');
await assert.rejects(checkReply(body([cite(git(r.repo, 'rev-parse', 'HEAD'))]), c), refusal('citation-invalid'));
assert.equal((await checkReply(body([cite(r.commit)]), c)).citations[0].scope, 'canon');
});
test('C2-06 playground cites pass but cannot state canon', async t => {
const r = repo(t), c = context(r), p = cite(r.commit, { path: 'Book One/Playground/What If.md', lines: [4, 4], quote: MARKER });
assert.equal((await checkReply(body([p]), c)).citations[0].scope, 'playground');
await assert.rejects(checkReply(body([{ ...p, as: 'canon' }]), c), refusal('playground-cited-as-canon'));
});
test('C2-06 independent universes refuse; a hybrid and only its sources pass', async t => {
const r = repo(t, { ...BASE, 'Crossover/Chapters/01.md': 'A hybrid fixture.\n' }), c = context(r);
const other = cite(r.commit, { path: 'Second World/Chapters/01.md', lines: [1, 1], quote: MARKER });
await assert.rejects(checkReply(body([cite(r.commit), other]), c), refusal('citation-cross-universe'));
c.mapping = mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'] }; return e; });
c.library = libraryReader(r.repo, r.commit, c.mapping);
const hybrid = cite(r.commit, { path: 'Crossover/Chapters/01.md', lines: [1, 1], quote: 'hybrid fixture' });
assert.equal((await checkReply(body([hybrid, cite(r.commit), other]), c)).citations.length, 3);
});
test('C2-06 web cites require allowed definition, this session fetch and kept quote', async t => {
const c = context(repo(t)), web = { url: 'https://fixture.invalid/page', quote: 'kept text' };
await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched'));
c.web.pages = [{ url: web.url, finalUrl: 'https://fixture.invalid/final', text: 'The kept\n text is here.' }];
assert.deepEqual(await checkReply(body([web]), c), { citations: [{ scope: 'web', agent: null }] });
assert.equal((await checkReply(body([{ ...web, url: c.web.pages[0].finalUrl }]), c)).citations.length, 1);
c.web.pages.push({ url: web.url, text: 'A later fetch.' });
assert.equal((await checkReply(body([{ ...web, quote: 'later fetch' }]), c)).citations.length, 1);
await assert.rejects(checkReply(body([{ ...web, quote: 'discarded bytes' }]), c), refusal('citation-unfetched'));
c.web.allowed = false;
await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched'));
});
test('C2-06 file origin is the path commit and trailer, while last follows served changes', async t => {
const r = repo(t), path = 'Book One/Characters/Mara Vell.md';
writeFileSync(join(r.repo, path), 'Agent fixture.\n'); git(r.repo, 'add', '-A');
git(r.repo, 'commit', '-qm', 'Note\n\nMosaic-Agent: acme/scribe');
const file = git(r.repo, 'rev-parse', 'HEAD');
writeFileSync(join(r.repo, 'Inbox Note.md'), 'Unrelated.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'unrelated');
const cited = git(r.repo, 'rev-parse', 'HEAD'), reader = libraryReader(r.repo, cited, mapping());
assert.notEqual(cited, file);
assert.deepEqual(await reader.origin(cited, path), { agent: 'acme/scribe', file });
assert.equal(await reader.last(path), file);
writeFileSync(join(r.repo, path), 'Human fixture.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'human');
assert.notEqual(await libraryReader(r.repo, git(r.repo, 'rev-parse', 'HEAD'), mapping()).last(path), file);
});
test('C2-06 origin uses literal paths even when a filename contains a glob', async t => {
const path = 'Book One/Characters/Mara*.md';
const r = repo(t, { ...BASE, [path]: 'Literal fixture.\n' }), original = r.commit;
writeFileSync(join(r.repo, 'Book One/Characters/Mara Vell.md'), 'Other fixture.\n'); git(r.repo, 'add', '-A');
git(r.repo, 'commit', '-qm', 'Other path\n\nMosaic-Agent: acme/other');
const reader = libraryReader(r.repo, git(r.repo, 'rev-parse', 'HEAD'), mapping());
assert.deepEqual(await reader.origin(git(r.repo, 'rev-parse', 'HEAD'), path), { agent: null, file: original });
assert.equal(await reader.last(path), original);
});
test('C2-06 reply limits fall back independently without coercion or inherited keys', () => {
for (const value of ['true', 1, null, [true], {}]) {
const r = resolveReplyLimits({ readable: true, document: { replyLimits: { requireCitation: value, citationsPerReply: 5 } } });
assert.equal(r.requireCitation, false); assert.equal(r.citationsPerReply, 5); assert.deepEqual(r.fallback, ['requireCitation']);
}
for (const value of [0, 1001, 2.5, true, '5']) {
const r = resolveReplyLimits({ readable: true, document: { replyLimits: { requireCitation: true, citationsPerReply: value } } });
assert.equal(r.requireCitation, true); assert.equal(r.citationsPerReply, 20); assert.deepEqual(r.fallback, ['citationsPerReply']);
}
for (const value of [null, [], 1, 'settings', undefined]) {
const r = resolveReplyLimits({ readable: true, document: { replyLimits: value } });
assert.equal(r.requireCitation, false); assert.equal(r.citationsPerReply, 20);
}
assert.equal(resolveReplyLimits({ readable: true, document: { replyLimits: Object.create({ requireCitation: true }) } }).requireCitation, false);
for (const document of [undefined, null, [], 'invalid']) assert.equal(resolveReplyLimits({ readable: true, document }).requireCitation, true);
assert.equal(resolveReplyLimits({ readable: false }).requireCitation, true);
assert.deepEqual(checkReplyLimits({ requireCitation: true, citationsPerReply: 1000 }), { requireCitation: true, citationsPerReply: 1000 });
for (const value of [{ unknown: 1 }, { requireCitation: 'true' }, { citationsPerReply: 0 }, null, []]) assert.equal(checkReplyLimits(value), null);
});
@@ -0,0 +1,91 @@
// Index tests (C2-05a): the cache key and its bound, failed reads, mapped-only contents, and the rebuild
// time on a 3,000-file fixture. Fixtures are made up and built under the scratch area.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { symlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { performance } from 'node:perf_hooks';
import { clearIndexCache, indexCacheSize, indexFor } from '../src/index-build.mjs';
import { git, mapping, MARKER } from './helpers.mjs';
import { note, scratchRepo } from './query-fixture.mjs';
const FILES = {
'Book One/Characters/Mara Vell.md': note('character', 'Mara Vell', MARKER),
'Book One/Chapters/Chapter 01.md': '# One\n[[Mara Vell]]\n',
'Inbox Note.md': `${MARKER}\n`,
};
// A mapping whose harbor universe has the given display name. Each name gives a new cache key.
const named = name => mapping(e => ({ ...e, universes: { ...e.universes, harbor: { ...e.universes.harbor, name } } }));
test('the same commit, repo path and mapping return the same index, and only mapped files are in it', async t => {
clearIndexCache();
const { repo, commit } = scratchRepo(t, FILES);
const a = await indexFor({ repo, commit, mapping: mapping() });
const b = await indexFor({ repo, commit, mapping: mapping() });
assert.equal(a, b);
assert.equal(indexCacheSize(), 1);
assert.equal(a.commit, commit);
assert.ok(a.files.has('Book One/Characters/Mara Vell.md'));
assert.ok(!a.files.has('Inbox Note.md'));
assert.deepEqual(a.names.get('mara vell'), ['Book One/Characters/Mara Vell.md']);
assert.deepEqual(a.linksIn.get('Book One/Characters/Mara Vell.md').map(l => l.path), ['Book One/Chapters/Chapter 01.md']);
});
test('a different repo path, mapping or commit gets its own index', async t => {
clearIndexCache();
const { root, repo, commit } = scratchRepo(t, FILES);
const base = await indexFor({ repo, commit, mapping: mapping() });
const alias = join(root, 'alias');
symlinkSync(repo, alias);
assert.notEqual(await indexFor({ repo: alias, commit, mapping: mapping() }), base);
assert.notEqual(await indexFor({ repo, commit, mapping: named('Renamed Harbor') }), base);
writeFileSync(join(repo, 'Book One/Places/New.md'), note('place', 'New'));
git(repo, 'add', '-A');
git(repo, 'commit', '-qm', 'second');
const next = git(repo, 'rev-parse', 'HEAD');
const later = await indexFor({ repo, commit: next, mapping: mapping() });
assert.notEqual(later, base);
assert.equal(later.commit, next);
assert.ok(later.files.has('Book One/Places/New.md'));
});
test('the cache holds at most eight indexes and evicts the least recently used', async t => {
clearIndexCache();
const { repo, commit } = scratchRepo(t, FILES);
const maps = Array.from({ length: 9 }, (_, i) => named(`Harbor ${i}`));
const first = await indexFor({ repo, commit, mapping: maps[0] });
for (const m of maps.slice(1)) await indexFor({ repo, commit, mapping: m });
assert.equal(indexCacheSize(), 8);
assert.notEqual(await indexFor({ repo, commit, mapping: maps[0] }), first);
assert.equal(indexCacheSize(), 8);
});
test('a failed read is not kept in the cache', async t => {
clearIndexCache();
const { repo } = scratchRepo(t, FILES);
await assert.rejects(indexFor({ repo, commit: '0'.repeat(40), mapping: mapping() }));
assert.equal(indexCacheSize(), 0);
});
test('a missing or unchecked mapping is refused before any read', async t => {
clearIndexCache();
const { repo, commit } = scratchRepo(t, FILES);
await assert.rejects(indexFor({ repo, commit, mapping: undefined }), { code: 'library-mapping-invalid' });
await assert.rejects(indexFor({ repo, commit, mapping: { kind: 'hybrid' } }), { code: 'library-mapping-invalid' });
});
test('a 3,000-file rebuild completes and its time is reported', { timeout: 300_000 }, async t => {
clearIndexCache();
const folders = ['Characters', 'Places', 'Research'];
const files = {};
for (let i = 0; i < 3000; i++) {
files[`Book One/${folders[i % 3]}/Note ${i}.md`] = note('character', `Note ${i}`, `Body ${i} [[Note ${(i + 1) % 3000}]]`);
}
const { repo, commit } = scratchRepo(t, files);
const started = performance.now();
const index = await indexFor({ repo, commit, mapping: mapping() });
const ms = performance.now() - started;
t.diagnostic(`3,000-file index rebuild: ${ms.toFixed(0)} ms`);
assert.equal(index.paths.length, 3000);
});
+106 -1
View File
@@ -1,5 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { checkMapping, classify, LibraryError } from '../src/index.mjs';
import { mapping, mappingEntry } from './helpers.mjs';
@@ -45,7 +49,7 @@ test('C2-01c: universe-level prefixes lie outside every book, in a plain univers
test('C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []', () => {
const m = mapping(e => { e.universes.idea = draft(); return e; });
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'], supporting: [] });
assert.ok(Object.isFrozen(m.universes.idea) && Object.isFrozen(m.universes.idea.books) && Object.isFrozen(m.universes.idea.playground));
refuses(e => { e.universes.idea = { ...draft(), books: ['New World'] }; return e; }, 'library-mapping-invalid', /universes.idea is a draft and takes no books/);
refuses(e => { e.universes.idea = { ...draft(), books: [] }; return e; }, 'library-mapping-invalid', /takes no books/);
@@ -56,6 +60,107 @@ test('C2-01c: a draft takes kind, name and playground only, and comes back froze
refuses(e => { e.universes.harbor.kind = kind; return e; }, 'library-mapping-invalid', /universes.harbor.kind must be "hybrid" or "draft"/);
});
test('C2-01d: supporting prefixes at book level, universe level, in a hybrid and in a draft, [] when absent', () => {
const m = mapping(e => {
e.universes.harbor.supporting = ['Book One/Notes/', 'Harbor Notes/'];
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], supporting: ['Crossover Notes/'] };
e.universes.idea = { ...draft(), supporting: ['Drafts/New World Notes/'] };
return e;
});
assert.deepEqual(m.universes.harbor.supporting, ['Book One/Notes/', 'Harbor Notes/']);
assert.deepEqual(m.universes.cross.supporting, ['Crossover Notes/']);
assert.deepEqual(m.universes.idea.supporting, ['Drafts/New World Notes/']);
assert.deepEqual(m.universes.second.supporting, []);
assert.ok(Object.isFrozen(m.universes.harbor.supporting) && Object.isFrozen(m.universes.second.supporting));
assert.deepEqual(mapping().universes.harbor.supporting, []);
});
test('C2-01d: each supporting prefix rule refuses with its code', () => {
const invalid = (change, pattern) => refuses(change, 'library-mapping-invalid', pattern);
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes']; return e; }, /universes.harbor.supporting holds "Book One\/Notes", which is not a folder path ending in \//);
invalid(e => { e.universes.harbor.supporting = ['Book One/../Notes/']; return e; }, /which is not a folder path ending in \//);
invalid(e => { e.universes.harbor.supporting = 'Book One/Notes/'; return e; }, /universes.harbor.supporting must be a list/);
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes/', 'Book One/Notes/Deep/']; return e; },
/supporting "Book One\/Notes\/" holds supporting "Book One\/Notes\/Deep\/"/);
invalid(e => { e.universes.harbor.supporting = ['Ideas/']; e.universes.idea = { ...draft(), supporting: ['Ideas/'] }; return e; },
/a supporting prefix is listed twice/);
invalid(e => { e.universes.harbor.supporting = ['Book One/']; return e; }, /universes.harbor.supporting "Book One\/" is the whole book folder "Book One"/);
invalid(e => { e.universes.harbor.books = ['Shelf/Book One']; e.universes.harbor.playground = []; e.universes.harbor.supporting = ['Shelf/']; return e; },
/universes.harbor.supporting "Shelf\/" holds its own book folder "Shelf\/Book One"/);
// Supporting is checked first in scope, so a supporting prefix holding a playground would hide it.
invalid(e => { e.universes.harbor.supporting = ['Book One/Workshop/']; e.universes.harbor.playground = ['Book One/Workshop/Playground/']; return e; },
/universes.harbor.supporting "Book One\/Workshop\/" holds playground "Book One\/Workshop\/Playground\/"/);
// Across entries any overlap would give one path two universes.
for (const [supporting, playground] of [['Ideas/', 'Ideas/'], ['Ideas/Deep/', 'Ideas/'], ['Ideas/', 'Ideas/Deep/']])
invalid(e => { e.universes.harbor.supporting = [supporting]; e.universes.idea = draft([playground]); return e; },
new RegExp(`universes.harbor.supporting "${supporting}" overlaps playground "${playground}" of universes.idea`));
refuses(e => { e.universes.harbor.supporting = ['Second World/Notes/']; return e; },
'library-supporting-other-universe', /universes.harbor.supporting "Second World\/Notes\/" lies inside book folder "Second World" of universes.second/);
refuses(e => { e.universes.second.books = ['Worlds/Second']; e.universes.harbor.supporting = ['Worlds/']; return e; },
'library-supporting-other-universe', /universes.harbor.supporting "Worlds\/" holds book folder "Worlds\/Second" of universes.second/);
refuses(e => { e.universes.idea = { ...draft(), supporting: ['Book One/Idea Notes/'] }; return e; },
'library-supporting-other-universe', /universes.idea.supporting "Book One\/Idea Notes\/" lies inside book folder "Book One" of universes.harbor/);
});
test('C2-01d: between the lists, a supporting prefix may equal its own playground, and a playground may hold its own supporting', () => {
const same = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/']; return e; });
assert.deepEqual([same.universes.harbor.playground, same.universes.harbor.supporting], [['Book One/Playground/'], ['Book One/Playground/']]);
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
assert.deepEqual(nested.universes.harbor.supporting, ['Book One/Playground/Notes/']);
});
test('C2-01d: editor is optional, kept when given, and refuses anything but a plain name and email', () => {
assert.equal(Object.hasOwn(mapping(), 'editor'), false);
const editor = { name: 'Her Name', email: '[email protected]' };
const m = mapping(e => ({ ...e, editor }));
assert.deepEqual(m.editor, editor);
assert.notEqual(m.editor, editor);
assert.ok(Object.isFrozen(m.editor));
const invalid = (value, pattern) => refuses(e => ({ ...e, editor: value }), 'library-mapping-invalid', pattern);
for (const value of [null, 'Her Name', 7, ['Her Name', '[email protected]']]) invalid(value, /editor must be an object/);
invalid({ ...editor, signingKey: 'x' }, /editor has unknown key "signingKey"/);
// sage-2925: an optional Gitea login for her save, checked as the entry's user is.
assert.deepEqual(mapping(e => ({ ...e, editor: { ...editor, user: 'her.login-1' } })).editor, { ...editor, user: 'her.login-1' });
for (const user of ['', 'her login', 'her@host', 'a'.repeat(101), 7, null])
invalid({ ...editor, user }, /editor.user must be a plain account name/);
invalid({ name: 'Her Name' }, /editor.email must be non-empty text with no control character, < or >/);
invalid({ email: '[email protected]' }, /editor.name must be non-empty text/);
for (const bad of ['', ' ', 7, null, 'a\u0000b', 'a\nb', 'a\u001fb', 'a\u007fb', 'a\u0085b', 'a\u009fb', '<her>', 'a<b', 'a>b']) {
invalid({ ...editor, name: bad }, /editor.name must be non-empty text with no control character, < or >/);
invalid({ ...editor, email: bad }, /editor.email must be non-empty text with no control character, < or >/);
}
// sage-3601, sage-3606: git's crud() strips whitespace and , : ; < > " \ ' from both ends of an
// ident, so a value starting or ending with one refuses. Inner < > and controls refuse above.
const ends = [' ', '\u00a0', '\u3000', ',', ':', ';', '"', "'", '\\'];
for (const c of ends) for (const padded of [`${c}Her Name`, `Her Name${c}`]) {
invalid({ ...editor, name: padded }, /editor.name must not start or end with whitespace or any of , : ; " ' \\/);
invalid({ ...editor, email: padded }, /editor.email must not start or end with whitespace or any of , : ; " ' \\/);
}
for (const c of ['<', '>', '\n', '\t']) for (const padded of [`${c}Her Name`, `Her Name${c}`, `Her${c}Name`])
invalid({ ...editor, name: padded }, /editor.name must be non-empty text with no control character, < or >/);
assert.equal(mapping(e => ({ ...e, editor: { name: 'Her Middle Name', email: '[email protected]' } })).editor.name, 'Her Middle Name');
for (const name of ['Doe, Jane Q.', 'Jane: "Q" Doe', "O'Neil", 'Back\\slash Name'])
assert.equal(mapping(e => ({ ...e, editor: { name, email: '[email protected]' } })).editor.name, name);
});
// sage-3606: an accepted editor commits under exactly the saved ident, as author and committer. A
// future git that strips more from an ident fails here.
test('C2-01d: an accepted editor round-trips through a real git commit unchanged', t => {
const editor = mapping(e => ({ ...e, editor: { name: 'Doe, Jane Q.', email: '[email protected].' } })).editor;
const root = mkdtempSync(join(tmpdir(), 'library-ident-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
// An ident in the environment would win over -c, so the commit runs without one. GIT_DIR and its
// family would beat -C (inside a hook, say), and git reads no global or system config (webby-0236 E5).
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_(AUTHOR|COMMITTER)_/.test(k)));
for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[k];
Object.assign(env, { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' });
const git = (...args) => execFileSync('git', ['-c', `user.name=${editor.name}`, '-c', `user.email=${editor.email}`,
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { encoding: 'utf8', env });
git('init', '-q');
git('commit', '-q', '--allow-empty', '-m', 'ident');
assert.equal(git('log', '-1', '--format=%an%n%ae%n%cn%n%ce'), `${editor.name}\n${editor.email}\n${editor.name}\n${editor.email}\n`);
});
test('no playground prefix holds another, and no book folder holds another', () => {
refuses(e => { e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Playground/Deep/']; return e; },
'library-mapping-invalid', /holds playground/);
+50
View File
@@ -0,0 +1,50 @@
// Scratch repos for the query and index tests (C2-05a). They are built under tmpdir(), as helpers.mjs
// repo() does; the gate sets TMPDIR under /mnt/storage/scratch/tmp, never /tmp (RULINGS "tmpdir flag").
// Bodies are made up.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { git } from './helpers.mjs';
// A repo with one commit holding `files` (path -> text). Removed when the test ends.
export function scratchRepo(t, files) {
const root = mkdtempSync(join(tmpdir(), 'r216-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const repo = join(root, 'books');
mkdirSync(repo);
git(repo, 'init', '-q', '-b', 'main');
for (const [path, text] of Object.entries(files)) {
mkdirSync(dirname(join(repo, path)), { recursive: true });
writeFileSync(join(repo, path), text);
}
git(repo, 'add', '-A');
git(repo, 'commit', '-qm', 'fixture');
return { root, repo, commit: git(repo, 'rev-parse', 'HEAD') };
}
// A note: front matter with a type and a name, optional extra front matter lines, then a body.
export const note = (type, name, body = '', extra = []) => ['---', `type: ${type}`, `name: ${name}`, ...extra, '---', body, ''].join('\n');
// The one exclusion the hybrid tests use, and the paths it hides. C2-15 (row 267) replaces the reading of
// `exclude` (a path prefix today); swapping these two constants is the whole change to the tests.
export const EXCLUSION = { exclude: 'Book One/Research' };
export const EXCLUDED_PATHS = ['Book One/Research/Ferry Logs.md'];
// C2-05c: synthetic scope fixture, including same-prefix precedence and a draft.
export const SUPPORT_FILES = {
'Book One/Characters/Canon.md': note('character', 'Canon', 'Scope fixture canon.'),
'Book One/Playground/Plan.md': note('summary', 'Plan', 'Scope fixture playground.'),
'Book One/Support/Notes/Helper.md': note('summary', 'Helper', 'Scope fixture supporting.'),
'Notes/HarborSupport/Loose.md': note('summary', 'Loose', 'Scope fixture supporting loose.'),
'Drafts/Idea/Support/Seed.md': note('summary', 'Seed', 'Scope fixture supporting draft.'),
};
export const withSupporting = entry => ({
...entry,
universes: {
...entry.universes,
harbor: { ...entry.universes.harbor,
playground: [...entry.universes.harbor.playground, 'Book One/Support/'],
supporting: ['Book One/Support/', 'Notes/HarborSupport/'] },
idea: { kind: 'draft', name: 'Idea', playground: ['Drafts/Idea/'], supporting: ['Drafts/Idea/Support/'] },
},
});
+466
View File
@@ -0,0 +1,466 @@
// Query tests for design 3 (C2-05a): universe isolation, scope labels, unmapped files, hybrids, paging
// under the reply cap, refusals and manuscript paging. Fixtures are made up and built under the scratch area.
import { test } from 'node:test';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import assert from 'node:assert/strict';
import { REPLY_CAP } from '../../harness/src/tools.mjs';
import { find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from '../src/query.mjs';
import { mapping, MARKER, git } from './helpers.mjs';
import { scratchRepo, note, EXCLUSION, EXCLUDED_PATHS, SUPPORT_FILES, withSupporting } from './query-fixture.mjs';
// A hybrid over harbor and second. Its one constraint is EXCLUSION (query-fixture.mjs) and it declares no
// books or playground.
const WITH_BLEND = e => ({
...e,
universes: {
...e.universes,
blend: {
kind: 'hybrid', name: 'Blend', sources: ['harbor', 'second'], books: [], playground: [],
constraints: [EXCLUSION], notes: 'made-up hybrid',
},
},
});
// PRD 1.14 levels (C2-01c): harbor gets a universe-level playground prefix outside every book, and a draft
// universe has only playground prefixes. Files there have book null.
const WITH_LEVELS = e => ({
...WITH_BLEND(e),
universes: {
...WITH_BLEND(e).universes,
harbor: { ...e.universes.harbor, playground: ['Book One/Playground/', 'Notes/Harbor/'] },
idea: { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'] },
},
});
const LEVEL_FILES = {
'Notes/Harbor/Scraps.md': note('summary', 'Scraps', `${MARKER} loose`),
'Notes/Harbor/Chapters/Idea Chapter.md': `# Idea\n${MARKER} unbooked chapter\n`,
'Drafts/New World/Characters/Rin.md': note('character', 'Rin', `${MARKER} draft`),
'Drafts/New World/Summary.md': note('summary', 'Summary', `${MARKER} draft`),
};
const FILES = {
'Book One/Characters/Mara Vell.md': note('character', 'Mara Vell', `Grey eyes. ${MARKER}`,
['appears_in: "[[Book One/Chapters/Chapter 01]]"', 'relationships:', ' - "[[Tomas]]"']),
'Book One/Characters/Tomas.md': note('character', 'Tomas', MARKER),
'Book One/Chapters/Chapter 01.md': `# One\n${MARKER} [[Mara Vell]] met [[Book One/Places/Harbor Town|the harbor]].\n`,
'Book One/Chapters/Chapter 02.md': 'Quiet. [[Ferry Logs]] [[Ilse]]\n',
'Book One/Places/Harbor Town.md': note('place', 'Harbor Town', `Salt. ${MARKER}`),
'Book One/Research/Ferry Logs.md': note('research', 'Ferry Logs', `Timetable. ${MARKER}`),
'Book One/Playground/What If.md': note('summary', 'What If', `${MARKER} [[Mara Vell]]`),
'Second World/Characters/Ilse.md': note('character', 'Ilse', `${MARKER} Ilse.`),
'Second World/Chapters/01.md': '# Elsewhere\n[[Ilse]] waits.\n',
'Inbox Note.md': `${MARKER}\n`,
'Book One/Board.canvas': '{"nodes":[]}\n',
};
const src = (ref, map = mapping()) => ({ repo: ref.repo, commit: ref.commit, mapping: map });
const paths = reply => reply.results.map(r => r.path);
test('the query reply cap equals the harness reply cap', () => {
assert.equal(QUERY_REPLY_CAP, REPLY_CAP);
});
test('find is bound to its universe and refuses a call without one', async t => {
const s = src(scratchRepo(t, FILES));
const names = paths(await find(s, { universe: 'harbor', query: '' }));
assert.ok(names.length > 0);
assert.ok(names.every(p => p.startsWith('Book One/')));
assert.ok(!names.includes('Second World/Characters/Ilse.md'));
await assert.rejects(find(s, { query: '' }), { code: 'query-argument-invalid' });
await assert.rejects(find(s, { universe: 'nowhere', query: '' }), { code: 'query-universe-unknown' });
});
test('every result carries its scope, and playground appears only when asked for', async t => {
const s = src(scratchRepo(t, FILES));
const canon = (await find(s, { universe: 'harbor', query: '' })).results;
assert.ok(canon.every(r => r.scope === 'canon'));
assert.ok(!canon.some(r => r.path.includes('/Playground/')));
const play = (await find(s, { universe: 'harbor', query: '', scope: 'playground' })).results;
assert.deepEqual(play.map(r => r.path), ['Book One/Playground/What If.md']);
assert.ok(play.every(r => r.scope === 'playground'));
});
test('unmapped files never appear in any reply', async t => {
const s = src(scratchRepo(t, FILES));
assert.deepEqual((await find(s, { universe: 'harbor', query: 'Inbox' })).results, []);
assert.deepEqual((await find(s, { universe: 'harbor', query: 'Board' })).results, []);
const hits = (await search(s, { universe: 'harbor', text: MARKER })).results;
assert.ok(hits.length > 0);
assert.ok(hits.every(r => r.path !== 'Inbox Note.md' && r.path !== 'Book One/Board.canvas'));
});
test('a hybrid sees its sources canon, minus exclusions, and no playground', async t => {
const s = src(scratchRepo(t, FILES), mapping(WITH_BLEND));
const reply = await find(s, { universe: 'blend', query: '' });
assert.deepEqual(paths(reply).sort(), [
'Book One/Characters/Mara Vell.md', 'Book One/Characters/Tomas.md', 'Book One/Chapters/Chapter 01.md',
'Book One/Chapters/Chapter 02.md', 'Book One/Places/Harbor Town.md',
'Second World/Characters/Ilse.md', 'Second World/Chapters/01.md',
]);
assert.ok(reply.results.every(r => r.scope === 'canon' && ['harbor', 'second'].includes(r.universe)));
assert.deepEqual((await find(s, { universe: 'blend', query: '', scope: 'playground' })).results, []);
assert.deepEqual((await find(s, { universe: 'blend', query: 'Ferry' })).results, []);
});
test('a hybrid gets a source file it can see, and not an excluded or foreign one', async t => {
const ref = scratchRepo(t, FILES);
const hybrid = src(ref, mapping(WITH_BLEND));
const ilse = await get(hybrid, { universe: 'blend', path: 'Second World/Characters/Ilse.md' });
assert.equal(ilse.scope, 'canon');
assert.ok(ilse.links_in.some(l => l.path === 'Second World/Chapters/01.md'));
assert.equal(await get(hybrid, { universe: 'blend', path: 'Book One/Research/Ferry Logs.md' }), null);
assert.equal(await get(src(ref), { universe: 'harbor', path: 'Second World/Characters/Ilse.md' }), null);
});
test('get lists visible links both ways and drops a target another universe cannot see', async t => {
const ref = scratchRepo(t, FILES);
const harbor = src(ref);
const ch1 = await get(harbor, { universe: 'harbor', path: 'Book One/Chapters/Chapter 01.md' });
assert.ok(ch1.links_out.some(l => l.path === 'Book One/Characters/Mara Vell.md' && l.line === 2));
assert.ok(ch1.links_out.some(l => l.path === 'Book One/Places/Harbor Town.md'));
const ch2 = await get(harbor, { universe: 'harbor', path: 'Book One/Chapters/Chapter 02.md' });
assert.ok(ch2.links_out.some(l => l.path === 'Book One/Research/Ferry Logs.md'));
assert.ok(!ch2.links_out.some(l => l.path === 'Second World/Characters/Ilse.md'));
const mara = await get(harbor, { universe: 'harbor', path: 'Book One/Characters/Mara Vell.md' });
assert.ok(mara.links_in.some(l => l.path === 'Book One/Chapters/Chapter 01.md'));
assert.ok(mara.links_in.some(l => l.path === 'Book One/Playground/What If.md'));
assert.ok(mara.links_out.some(l => l.path === 'Book One/Characters/Tomas.md' && l.field === 'relationships'));
assert.ok(mara.links_out.some(l => l.path === 'Book One/Chapters/Chapter 01.md' && l.field === 'appears_in'));
const blendCh2 = await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path: 'Book One/Chapters/Chapter 02.md' });
assert.ok(blendCh2.links_out.some(l => l.path === 'Second World/Characters/Ilse.md'));
});
test('a long list pages by position, never cuts an item, and returns 50 per page', async t => {
const files = { ...FILES };
for (let i = 1; i <= 120; i++) files[`Book One/Places/Note ${i}.md`] = note('place', `Note ${i}`);
const s = src(scratchRepo(t, files));
const seen = [];
let offset = 0;
for (let pages = 0; pages < 10; pages++) {
const reply = await find(s, { universe: 'harbor', query: 'Note', offset });
assert.ok(JSON.stringify(reply).length <= QUERY_REPLY_CAP);
assert.ok(reply.results.length <= LOOKUP_LIMIT);
if (pages === 0) assert.equal(reply.results.length, LOOKUP_LIMIT);
seen.push(...paths(reply));
if (reply.next === null) break;
offset = reply.next;
}
assert.equal(seen.length, 120);
assert.equal(new Set(seen).size, 120);
});
test('a page stays under the reply cap when items are large', async t => {
const big = 'x'.repeat(2000);
const files = { ...FILES };
for (let i = 1; i <= 40; i++) files[`Book One/Places/Big ${i}.md`] = note('place', `Big ${i}`, '', [`appears_in: "${big}"`]);
const s = src(scratchRepo(t, files));
const first = await find(s, { universe: 'harbor', query: 'Big' });
assert.ok(JSON.stringify(first).length <= QUERY_REPLY_CAP);
assert.ok(first.results.length > 0 && first.results.length < 40);
assert.notEqual(first.next, null);
const seen = [];
let offset = 0;
for (let pages = 0; pages < 10; pages++) {
const reply = await find(s, { universe: 'harbor', query: 'Big', offset });
assert.ok(JSON.stringify(reply).length <= QUERY_REPLY_CAP);
seen.push(...paths(reply));
if (reply.next === null) break;
offset = reply.next;
}
assert.equal(new Set(seen).size, 40);
});
test('a search line too long to fit alone comes back marked, with no excerpt, and the other hits still return', async t => {
const huge = 'y'.repeat(QUERY_REPLY_CAP + 1000);
const s = src(scratchRepo(t, { ...FILES, 'Book One/Places/Wall.md': note('place', 'Wall', `${huge} ${MARKER}`) }));
const reply = await search(s, { universe: 'harbor', text: 'yyyyyyyy' });
assert.deepEqual(reply.results, [{ path: 'Book One/Places/Wall.md', line: 5, excerpt: null, excerpt_too_long: true, scope: 'canon' }]);
const mixed = await search(s, { universe: 'harbor', text: MARKER.toLowerCase() });
assert.ok(mixed.results.some(r => r.excerpt_too_long === true));
assert.ok(mixed.results.some(r => typeof r.excerpt === 'string' && !('excerpt_too_long' in r)));
assert.ok(JSON.stringify(mixed).length <= QUERY_REPLY_CAP);
});
test('a find item that cannot fit alone is refused, not cut', async t => {
const huge = 'y'.repeat(QUERY_REPLY_CAP + 1000);
const s = src(scratchRepo(t, { ...FILES, 'Book One/Places/Wall.md': note('place', 'Wall', '', [`appears_in: "${huge}"`]) }));
await assert.rejects(find(s, { universe: 'harbor', query: 'Wall' }), { code: 'query-item-too-large' });
});
test('get pages its lines, and the next line picks up where the page stopped', async t => {
const body = `${Array.from({ length: 500 }, (_, i) => `Line ${i + 1} ${'z'.repeat(120)}`).join('\n')}\n`;
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Long.md': body }));
const first = await get(s, { universe: 'harbor', path: 'Book One/Chapters/Long.md' });
assert.equal(first.lines[0][0], 1);
assert.ok(first.lines.length < 501);
assert.notEqual(first.next, null);
let from = 1, count = 0;
for (let pages = 0; pages < 10; pages++) {
const page = await get(s, { universe: 'harbor', path: 'Book One/Chapters/Long.md', from });
count += page.lines.length;
if (page.next === null) break;
from = page.next;
}
assert.equal(count, 501);
});
test('get refuses a file whose front matter alone is over the cap, and a line alone is over the cap', async t => {
const huge = 'q'.repeat(QUERY_REPLY_CAP + 1000);
const s = src(scratchRepo(t, {
...FILES,
'Book One/Places/Heavy.md': note('place', 'Heavy', '', [`appears_in: "${huge}"`]),
'Book One/Chapters/Wide.md': `${huge}\n`,
}));
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Places/Heavy.md' }), { code: 'query-item-too-large' });
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Chapters/Wide.md' }), { code: 'query-item-too-large' });
});
test('get pages the links of a hub note, every page carries the front matter, and the pages add up', async t => {
const files = { ...FILES };
for (let i = 1; i <= 900; i++) files[`Book One/Chapters/Mention ${i}.md`] = `[[Tomas]] ${'w'.repeat(60)}\n`;
const s = src(scratchRepo(t, files));
const seen = [];
const PAST_END = 1000;
let linksFrom = 0, pages = 0, lines = 0, from = 1;
for (; pages < 40; pages++) {
const page = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', from, links_from: linksFrom });
assert.ok(JSON.stringify(page).length <= QUERY_REPLY_CAP);
assert.equal(page.frontmatter.name, 'Tomas');
seen.push(...page.links_in.map(l => l.path));
if (from !== PAST_END) lines += page.lines.length;
// Once the lines are read, ask past the last one: the page then carries links alone.
from = page.next ?? PAST_END;
if (page.links_next === null && page.next === null) break;
if (page.links_next !== null) linksFrom = page.links_next;
}
assert.ok(pages > 1, 'the hub needed more than one page');
assert.ok(pages < 40);
assert.equal(seen.filter(p => p.startsWith('Book One/Chapters/Mention ')).length, 900);
assert.equal(new Set(seen).size, seen.length, 'no link comes back twice');
assert.equal(lines, 6, 'the five lines of the note and the empty one after its last newline');
const tail = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', from: PAST_END });
assert.deepEqual(tail.lines, []);
assert.equal(tail.next, null);
});
test('get rejects a bad links_from', async t => {
const s = src(scratchRepo(t, FILES));
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', links_from: -1 }), { code: 'query-argument-invalid' });
const past = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', links_from: 99 });
assert.deepEqual([past.links_out, past.links_in, past.links_next], [[], [], null]);
});
test('search returns scoped line hits, case-insensitively, and refuses empty text', async t => {
const s = src(scratchRepo(t, FILES));
const reply = await search(s, { universe: 'harbor', text: 'marker' });
assert.ok(reply.results.length > 0);
assert.ok(reply.results.every(r => r.scope === 'canon' && r.path.startsWith('Book One/') && Number.isInteger(r.line)));
assert.ok(reply.results.some(r => r.path === 'Book One/Chapters/Chapter 01.md' && r.line === 2));
await assert.rejects(search(s, { universe: 'harbor', text: '' }), { code: 'query-argument-invalid' });
});
test('a manuscript call returns one chapter, at most 400 lines, and names its next line', async t => {
const long = `${Array.from({ length: 900 }, (_, i) => `Scene ${i + 1}`).join('\n')}\n`;
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Long Chapter.md': long }));
const first = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Long Chapter' });
assert.equal(first.lines.length, MANUSCRIPT_LINES);
assert.equal(first.next, MANUSCRIPT_LINES + 1);
const second = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Long Chapter', from: first.next });
assert.equal(second.lines[0][0], MANUSCRIPT_LINES + 1);
});
test('a manuscript call without a chapter returns the first chapter in path order and names the next', async t => {
const s = src(scratchRepo(t, FILES));
const first = await manuscript(s, { universe: 'harbor', book: 'Book One' });
assert.equal(first.path, 'Book One/Chapters/Chapter 01.md');
assert.equal(first.next_chapter, 'Chapter 02');
const last = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Chapter 02' });
assert.equal(last.path, 'Book One/Chapters/Chapter 02.md');
assert.equal(last.next_chapter, null);
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Nope' }), null);
});
test('a chapter name that matches two visible files is refused', async t => {
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Part/Chapter 01.md': 'Again.\n' }));
await assert.rejects(manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Chapter 01' }), { code: 'query-chapter-ambiguous' });
});
test('a hybrid reads a source chapter through its declared sources only', async t => {
const ref = scratchRepo(t, FILES);
const hybrid = await manuscript(src(ref, mapping(WITH_BLEND)), { universe: 'blend', book: 'Second World', chapter: '01' });
assert.equal(hybrid.path, 'Second World/Chapters/01.md');
assert.equal(hybrid.scope, 'canon');
assert.equal(await manuscript(src(ref), { universe: 'harbor', book: 'Second World', chapter: '01' }), null);
});
test('universe info reports a hybrid sources, constraints and notes, and refuses an unknown universe', async t => {
const s = src(scratchRepo(t, FILES), mapping(WITH_BLEND));
const blend = await universeInfo(s, { universe: 'blend' });
assert.equal(blend.kind, 'hybrid');
assert.deepEqual(blend.sources, ['harbor', 'second']);
assert.deepEqual(blend.constraints, [EXCLUSION]);
assert.equal(blend.notes, 'made-up hybrid');
const harbor = await universeInfo(s, { universe: 'harbor' });
assert.equal(harbor.kind, 'universe');
assert.equal(harbor.sources, undefined);
await assert.rejects(universeInfo(s, { universe: 'ghost' }), { code: 'query-universe-unknown' });
});
test('an exclusion hides what it names from a hybrid and from nothing else', async t => {
const ref = scratchRepo(t, FILES);
const hybrid = src(ref, mapping(WITH_BLEND));
const seen = paths(await find(hybrid, { universe: 'blend', query: '' }));
for (const path of EXCLUDED_PATHS) assert.ok(!seen.includes(path), `${path} stays hidden`);
const own = paths(await find(src(ref), { universe: 'harbor', query: '' }));
for (const path of EXCLUDED_PATHS) assert.ok(own.includes(path), `${path} stays visible to its own universe`);
});
test('PRD 1.14: a lookup that names a book leaves book-null files out', async t => {
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
const all = paths(await find(s, { universe: 'harbor', query: '', scope: 'playground' }));
assert.deepEqual(all.sort(), ['Book One/Playground/What If.md', 'Notes/Harbor/Chapters/Idea Chapter.md', 'Notes/Harbor/Scraps.md']);
const booked = await find(s, { universe: 'harbor', query: '', scope: 'playground', book: 'Book One' });
assert.deepEqual(paths(booked), ['Book One/Playground/What If.md']);
assert.ok(booked.results.every(r => r.book === 'Book One'));
const hits = (await search(s, { universe: 'harbor', text: 'unbooked', scope: 'playground' })).results;
assert.deepEqual(hits.map(r => r.path), ['Notes/Harbor/Chapters/Idea Chapter.md']);
assert.deepEqual((await search(s, { universe: 'harbor', text: 'unbooked', scope: 'playground', book: 'Book One' })).results, []);
});
test('PRD 1.14: library_manuscript never reads a book-null file', async t => {
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
// The unbooked file sits under a manuscript folder, so it has type manuscript and book null.
const listed = await find(s, { universe: 'harbor', query: 'Idea', scope: 'playground' });
assert.deepEqual(listed.results.map(r => [r.path, r.type, r.book]), [['Notes/Harbor/Chapters/Idea Chapter.md', 'manuscript', null]]);
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'playground', chapter: 'Idea Chapter' }), null);
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'playground' }), null);
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Notes', scope: 'playground', chapter: 'Idea Chapter' }), null);
});
test('PRD 1.14: a draft universe has no canon, so its lookups return files only with scope playground', async t => {
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
assert.deepEqual((await find(s, { universe: 'idea', query: '' })).results, []);
assert.deepEqual((await search(s, { universe: 'idea', text: MARKER })).results, []);
const found = await find(s, { universe: 'idea', query: '', scope: 'playground' });
assert.deepEqual(paths(found).sort(), ['Drafts/New World/Characters/Rin.md', 'Drafts/New World/Summary.md']);
assert.ok(found.results.every(r => r.scope === 'playground' && r.universe === 'idea' && r.book === null));
const hits = (await search(s, { universe: 'idea', text: 'draft', scope: 'playground' })).results;
assert.equal(hits.length, 2);
const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' });
assert.equal(rin.scope, 'playground');
assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' }), null);
});
test('PRD 1.14: library_universe on a draft returns its kind, name and prefixes', async t => {
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
const draft = await universeInfo(s, { universe: 'idea' });
assert.equal(draft.kind, 'draft');
assert.equal(draft.name, 'New World');
assert.deepEqual(draft.playground, ['Drafts/New World/']);
assert.deepEqual(draft.books, []);
assert.equal(draft.sources, undefined);
});
test('a hybrid gets a source playground file as null, while the source gets it with scope playground', async t => {
const ref = scratchRepo(t, FILES);
const path = 'Book One/Playground/What If.md';
assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path, scope: 'playground' }), null);
const own = await get(src(ref), { universe: 'harbor', path, scope: 'playground' });
assert.equal(own.scope, 'playground');
});
// mos-1330 item3: the new get contract defaults to canon even for an explicit playground path.
test('C2-05c get without scope returns null for a playground path', async t => {
const s = src(scratchRepo(t, FILES));
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Playground/What If.md' }), null);
});
// Row284: scope is explicit for get as well as lists; blob and lines share a served snapshot.
test('C2-05c supporting find/search/get are opt-in at book, universe and draft level', async t => {
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(withSupporting));
const expected = ['Book One/Support/Notes/Helper.md', 'Notes/HarborSupport/Loose.md'];
assert.deepEqual(paths(await find(s, { universe: 'harbor', query: '', scope: 'supporting' })), expected);
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture', scope: 'supporting' })).results.map(x => x.path), expected);
assert.deepEqual(paths(await find(s, { universe: 'harbor', query: '' })), ['Book One/Characters/Canon.md']);
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture' })).results.map(x => x.path), ['Book One/Characters/Canon.md']);
for (const path of expected) {
assert.equal(await get(s, { universe: 'harbor', path }), null);
assert.equal(await get(s, { universe: 'harbor', path, scope: 'playground' }), null);
assert.equal((await get(s, { universe: 'harbor', path, scope: 'supporting' })).scope, 'supporting');
assert.equal(await get(s, { universe: 'second', path, scope: 'supporting' }), null);
}
const draftPath = 'Drafts/Idea/Support/Seed.md';
assert.deepEqual(paths(await find(s, { universe: 'idea', query: '', scope: 'supporting' })), [draftPath]);
assert.equal((await get(s, { universe: 'idea', path: draftPath, scope: 'supporting' })).scope, 'supporting');
assert.equal(await get(s, { universe: 'idea', path: draftPath }), null);
assert.equal((await find(s, { universe: 'harbor', query: '', book: 'Book One', scope: 'supporting' })).results.length, 1);
});
test('C2-05c an absent supporting prefix returns nothing without refusal', async t => {
const s = src(scratchRepo(t, SUPPORT_FILES));
assert.deepEqual((await find(s, { universe: 'harbor', query: '', scope: 'supporting' })).results, []);
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture', scope: 'supporting' })).results, []);
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Characters/Canon.md', scope: 'supporting' }), null);
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Support/Notes/Helper.md', scope: 'supporting' }), null);
});
test('C2-05c each get blob equals its served tree object, even after source HEAD advances', async t => {
const ref = scratchRepo(t, SUPPORT_FILES), s = src(ref, mapping(withSupporting));
const canon = 'Book One/Characters/Canon.md';
const priorBlob = git(ref.repo, 'rev-parse', `${ref.commit}:${canon}`);
writeFileSync(join(ref.repo, canon), note('character', 'Canon changed', 'New HEAD must not be read.'));
git(ref.repo, 'add', '-A'); git(ref.repo, 'commit', '-qm', 'advance head fixture');
assert.notEqual(git(ref.repo, 'rev-parse', `HEAD:${canon}`), priorBlob);
for (const [scope, path] of [['canon', canon], ['playground', 'Book One/Playground/Plan.md'], ['supporting', 'Book One/Support/Notes/Helper.md']]) {
const result = await get(s, { universe: 'harbor', path, scope });
assert.equal(result.commit, ref.commit);
assert.equal(result.blob, git(ref.repo, 'rev-parse', `${ref.commit}:${path}`));
assert.ok(!JSON.stringify(result.lines).includes('New HEAD must not be read'));
}
});
test('C2-05c full outgoing and incoming links stay in scope order across pages without losing cross-scope links', async t => {
const files = {}, groups = { supporting: [], playground: [], canon: [] };
for (const [scope, folder] of [['canon', 'Book One/Characters'], ['playground', 'Book One/Playground'], ['supporting', 'Book One/Support']]) {
for (let n = 0; n < 100; n++) {
const path = `${folder}/${scope}-${String(n).padStart(3, '0')}-${'x'.repeat(80)}.md`;
groups[scope].push(path);
files[path] = `[[Book One/Characters/Hub]]\n`;
}
}
const all = [...groups.canon, ...groups.playground, ...groups.supporting];
files['Book One/Characters/Hub.md'] = all.map(path => `[[${path.slice(0, -3)}]]`).join('\n');
// Other-universe links are never returned, regardless of rank.
files['Second World/Other.md'] = '[[Book One/Characters/Hub]]\n';
const s = src(scratchRepo(t, files), mapping(withSupporting));
const outs = [], ins = [];
let offset = 0, pages = 0;
for (; pages < 20; pages++) {
const page = await get(s, { universe: 'harbor', path: 'Book One/Characters/Hub.md', from: 999_999, links_from: offset });
assert.ok(JSON.stringify(page).length <= QUERY_REPLY_CAP);
outs.push(...page.links_out.map(x => x.path)); ins.push(...page.links_in.map(x => x.path));
if (page.links_next === null) break;
offset = page.links_next;
}
const expected = [...groups.supporting, ...groups.playground, ...groups.canon];
assert.ok(pages > 0 && pages < 20, 'links occupy multiple bounded pages');
assert.deepEqual(outs, expected);
assert.deepEqual(ins, expected);
assert.equal(new Set(outs).size, 300);
assert.equal(new Set(ins).size, 300);
});
test('C2-05c manuscript still refuses supporting rather than silently widening its scope', async t => {
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(withSupporting));
await assert.rejects(manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'supporting' }), { code: 'query-argument-invalid' });
});
test('C2-05c scope expansion keeps hybrid sources canon-only and refuses all/array scopes', async t => {
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(entry => withSupporting(WITH_BLEND(entry))));
assert.deepEqual((await find(s, { universe: 'blend', query: '', scope: 'supporting' })).results, []);
assert.equal(await get(s, { universe: 'blend', path: 'Book One/Support/Notes/Helper.md', scope: 'supporting' }), null);
assert.equal((await get(s, { universe: 'blend', path: 'Book One/Characters/Canon.md' })).scope, 'canon');
for (const scope of ['all', ['canon', 'supporting']])
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Characters/Canon.md', scope }), { code: 'query-argument-invalid' });
});
+41
View File
@@ -53,3 +53,44 @@ test('C2-01c: a canon file linking into a draft gives R6, and into its own unive
assert.deepEqual(codes(path), [], path);
}
});
// Supporting prefixes at three levels (C2-01d). Harbor's book-level prefix sits in both lists, and a
// second book-level prefix is supporting only.
const supporting = () => mapping(e => {
e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Sketches/'];
e.universes.harbor.supporting = ['Book One/Playground/', 'Book One/Notes/', 'Harbor Notes/'];
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'], supporting: ['Drafts/New World Notes/'] };
return e;
});
test('C2-01d: each supporting level gives its universe, and its book or null', () => {
const m = supporting();
assert.deepEqual(classify(m, 'Book One/Notes/Characters/Mara.md'),
{ scope: 'supporting', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
assert.deepEqual(classify(m, 'Harbor Notes/Places/Island.md'),
{ scope: 'supporting', universe: 'harbor', book: null, type: 'place', folder: 'Places' });
assert.deepEqual(classify(m, 'Harbor Notes/Loose.md'), { scope: 'supporting', universe: 'harbor', book: null, type: null, folder: null });
assert.deepEqual(classify(m, 'Drafts/New World Notes/Chapters/Open.md'),
{ scope: 'supporting', universe: 'idea', book: null, type: 'manuscript', folder: 'Chapters' });
assert.deepEqual(classify(m, 'Drafts/New World/Ana.md'), { scope: 'playground', universe: 'idea', book: null, type: null, folder: null });
assert.deepEqual(classify(m, 'Book One/Places/Dock.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: 'place', folder: 'Places' });
});
test('C2-01d: supporting comes before playground; a folder in both lists is supporting, one in the playground only stays playground', () => {
const m = supporting();
assert.deepEqual(classify(m, 'Book One/Playground/What If.md'),
{ scope: 'supporting', universe: 'harbor', book: 'Book One', type: null, folder: null });
assert.deepEqual(classify(m, 'Book One/Sketches/Research/Tides.md'),
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: 'research', folder: 'Research' });
// A playground may hold a supporting prefix of its own entry: the inner folder is supporting.
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
assert.equal(classify(nested, 'Book One/Playground/Notes/A.md').scope, 'supporting');
assert.equal(classify(nested, 'Book One/Playground/A.md').scope, 'playground');
});
test('C2-01d: only .md files are supporting; a .canvas or .base under a supporting prefix is unmapped', () => {
const m = supporting();
for (const path of ['Book One/Notes/Board.canvas', 'Harbor Notes/Table.base', 'Drafts/New World Notes/Map.canvas',
'Harbor Notes/cover.png', 'Harbor Notesx/a.md'])
assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path);
});
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { LibraryError, supportingPath } from '../src/index.mjs';
import { mapping } from './helpers.mjs';
// C2-01d (M12): the path checks an agent write passes. The tree is a listTree array built by hand,
// with a symlink, a gitlink, case twins and an NFC name, so no git is needed.
const m = mapping(e => {
e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Sketches/'];
e.universes.harbor.supporting = ['Book One/Playground/', 'Book One/Notes/', 'Harbor Notes/'];
e.universes.second.supporting = ['Second Notes/'];
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'], supporting: ['Drafts/New World Notes/'] };
return e;
});
const blob = (path, mode = '100644') => ({ mode, type: mode === '160000' ? 'commit' : 'blob', oid: '0'.repeat(40), size: 1, path });
const tree = [
blob('Book One/Characters/Mara Vell.md'),
blob('Book One/Notes/Existing.md'),
blob('Book One/Notes/Linked', '120000'),
blob('Book One/Notes/Vendor', '160000'),
blob('Book One/Notes/Pointer.md', '120000'),
blob('Book One/Notes/Case/Twin.md'),
blob('Book One/Notes/Café.md'),
blob('Harbor Notes/Places/Island.md'),
];
const refuses = (universe, path, pattern) => assert.throws(() => supportingPath(m, universe, path, tree),
e => e instanceof LibraryError && e.code === 'library-write-story-path' && pattern.test(e.message), JSON.stringify(path));
test('C2-01d: a path passes at book level, universe level, in a draft and in a folder in both lists', () => {
for (const [universe, path] of [['harbor', 'Book One/Notes/Characters/New.md'], ['harbor', 'Book One/Notes/Existing.md'],
['harbor', 'Harbor Notes/Places/Island.md'], ['harbor', 'Harbor Notes/Loose.md'], ['idea', 'Drafts/New World Notes/Ana.md'],
['harbor', 'Book One/Playground/What If.md'], ['second', 'Second Notes/A.md'], ['harbor', 'Book One/Notes/Café.md']])
assert.equal(supportingPath(m, universe, path, tree), path);
assert.equal(supportingPath(m, 'harbor', 'Book One/Notes/x.md', []), 'Book One/Notes/x.md');
});
test('C2-01d: a path of exactly 1024 bytes passes, and 1025 bytes made of multi-byte characters refuses', () => {
const prefix = 'Harbor Notes/';
const exact = `${prefix}${'a'.repeat(1024 - prefix.length - 3)}.md`;
assert.equal(Buffer.byteLength(exact), 1024);
assert.equal(supportingPath(m, 'harbor', exact, tree), exact);
// é is two bytes: 1025 bytes but far fewer characters.
const name = 'é'.repeat((1025 - prefix.length - 3 - 1) / 2);
const long = `${prefix}${name}a.md`;
assert.equal(Buffer.byteLength(long), 1025);
assert.ok(long.length < 1024);
refuses('harbor', long, /is longer than 1024 bytes/);
});
test('C2-01d: a path outside the universe\'s supporting folders refuses', () => {
const outside = /is not under a supporting folder of universe/;
for (const path of ['Inbox Note.md', 'Book One/Notes/Board.canvas', 'Book One/Notes/Table.base', 'Book One/Notes/cover.png',
'Book One/Characters/Mara Vell.md', 'Book One/Sketches/Idea.md', 'Drafts/New World/Ana.md', 'Second Notes/A.md', 'Book One/Notes.md'])
refuses('harbor', path, outside);
refuses('idea', 'Book One/Notes/A.md', /universe "idea"/);
refuses('nowhere', 'Book One/Notes/A.md', /universe "nowhere"/);
});
test('C2-01d: malformed paths refuse by class, in order', () => {
refuses('harbor', '/Book One/Notes/A.md', /"\/Book One\/Notes\/A.md" is absolute/);
for (const path of ['Book One/Notes/../Notes/A.md', 'Book One/Notes/./A.md', 'Book One//Notes/A.md', 'Book One/Notes/A/', 'Book One/Notes/..'])
refuses('harbor', path, /has an empty, \. or \.\. segment/);
refuses('harbor', 'Book One/Notes/A\\B.md', /holds a backslash/);
for (const c of ['\u0000', '\u0001', '\n', '\u001f', '\u007f', '\u0085', '\u009f'])
assert.throws(() => supportingPath(m, 'harbor', `Book One/Notes/A${c}.md`, tree),
e => e.code === 'library-write-story-path' && e.message === 'library-write-story-path: the path holds a control character');
for (const path of [7, null, undefined, 1n, ['Book One/Notes/A.md']]) refuses('harbor', path, /the path must be a string/);
// A backslash in a path that is also absolute names the backslash: the order is fixed.
refuses('harbor', '/a\\b.md', /holds a backslash/);
assert.throws(() => supportingPath(m, 'harbor', 'Book One/Notes/A.md', { 'Book One/Notes/A.md': '100644' }), TypeError);
assert.throws(() => supportingPath(m, 'harbor', 'Book One/Notes/A.md'), TypeError);
});
test('C2-01d: a symlink or gitlink on the way, or at the path itself, refuses', () => {
refuses('harbor', 'Book One/Notes/Linked/A.md', /goes through the symlink or gitlink "Book One\/Notes\/Linked"/);
refuses('harbor', 'Book One/Notes/Vendor/Deep/A.md', /goes through the symlink or gitlink "Book One\/Notes\/Vendor"/);
refuses('harbor', 'Book One/Notes/Pointer.md', /goes through the symlink or gitlink "Book One\/Notes\/Pointer.md"/);
});
test('C2-01d: a name that differs from a tree name only in case or Unicode form refuses, at the file or any folder', () => {
refuses('harbor', 'Book One/Notes/existing.md', /differs only in case or Unicode form from "Book One\/Notes\/Existing.md"/);
refuses('harbor', 'Book One/Notes/case/New.md', /differs only in case or Unicode form from "Book One\/Notes\/Case"/);
refuses('harbor', 'Book One/Notes/CASE/Twin.md', /from "Book One\/Notes\/Case"/);
refuses('harbor', 'Harbor notes/A.md', /is not under a supporting folder/);
// NFD e + combining acute against the NFC name in the tree: one file on APFS.
refuses('harbor', 'Book One/Notes/Café.md', /differs only in case or Unicode form from "Book One\/Notes\/Café.md"/);
// A tree that already holds case twins refuses a write to either (R7).
const twins = [...tree, blob('Book One/Notes/existing.md')];
for (const path of ['Book One/Notes/Existing.md', 'Book One/Notes/existing.md'])
assert.throws(() => supportingPath(m, 'harbor', path, twins), e => e.code === 'library-write-story-path' && /differs only in case/.test(e.message));
});
+13 -2
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { writeFileSync } from 'node:fs';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
@@ -70,12 +70,23 @@ test('with --mapping the survey adds scope, resolver step and problem counts', a
assert.equal(out.code, 0, out.stderr);
const tail = out.stdout.trimEnd().split('\n').slice(-3);
assert.deepEqual(tail, [
'scopes canon 5, playground 1, unmapped 3; indexed 6',
'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',
'resolved root 2, vault-prefix 1, relative 2, shortest 3, unresolved 0',
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0']);
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
});
test('C2-01d: with --mapping the survey counts supporting files', async t => {
const r = repo(t);
const entry = JSON.parse(readFileSync(join(FIXTURES, 'mapping.json'), 'utf8'));
entry.universes.harbor.supporting = ['Book One/Places/'];
const file = join(r.root, 'supporting.json');
writeFileSync(file, JSON.stringify(entry));
const out = await run(['survey', r.repo, '--mapping', file]);
assert.equal(out.code, 0, out.stderr);
assert.match(out.stdout, /^scopes canon 4, playground 1, supporting 1, unmapped 3; indexed 6$/m);
});
test('survey exit codes: usage 4, bad mapping 3, unreadable repo 2', async t => {
const r = repo(t);
for (const args of [[], ['scan', r.repo], ['survey'], ['survey', r.repo, '--mapping'], ['survey', r.repo, '--other'], ['survey', r.repo, r.repo]])
-1
View File
@@ -60,7 +60,6 @@ export const RUNNER_FILES = Object.freeze([
"packages/bus/src/landing.mjs",
"packages/business/src/vocabulary.mjs",
"packages/plugins/src/kinds.mjs",
"packages/library/src/reply-protocol.mjs",
"packages/processing-control/src/state.mjs",
"packages/tasks/src/requirements.mjs",
].map((p) => join(REPO, p)));