From b9ef816ff78639c3615dcd2ee551450ca6034ae5 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 23 Oct 2026 14:10:13 -0500 Subject: [PATCH] launchpad: row 217 C2-06 work in progress, UNTESTED, handed to Beakley (mos-1500) Guard, limits, M11 fence, broker guard/page store/check event, BusError reason, runtime wiring and the CLI replies verb. No runner work, no tests, nothing run. BUILD.md lists the rulings, the row 225 contract and what is left. --- agents/launchpad/work/queue-217/BUILD.md | 124 ++++++++++++++ packages/bus/src/broker.mjs | 141 +++++++++++++++- packages/bus/src/client.mjs | 3 +- packages/bus/src/runtime.mjs | 11 +- packages/bus/src/server.mjs | 28 +++- packages/business/src/business.mjs | 2 +- packages/business/src/organization.mjs | 45 +++++- packages/cli/src/limits.mjs | 15 +- packages/library/src/guard.mjs | 197 +++++++++++++++++++++++ packages/library/src/index.mjs | 2 + packages/library/src/limits.mjs | 41 +++++ 11 files changed, 589 insertions(+), 20 deletions(-) create mode 100644 agents/launchpad/work/queue-217/BUILD.md create mode 100644 packages/library/src/guard.mjs create mode 100644 packages/library/src/limits.mjs diff --git a/agents/launchpad/work/queue-217/BUILD.md b/agents/launchpad/work/queue-217/BUILD.md new file mode 100644 index 000000000..30c00d7eb --- /dev/null +++ b/agents/launchpad/work/queue-217/BUILD.md @@ -0,0 +1,124 @@ +# 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 `.` 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. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 163a7ec65..20590a194 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -204,15 +204,26 @@ const canonical = (x) => Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x; export class BusError extends Error { - constructor(code) { + // C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on. + constructor(code, reason) { 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 = []) { @@ -393,6 +404,11 @@ 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; @@ -1412,7 +1428,10 @@ 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); + if (!s.human && !s.reader && s.business === record.business && s.run === record.run) { + this.#sessions.delete(cap); + this.#pages.delete(cap); + } return this.#store.transaction(() => { const h = this.#own(session); const released = h?.op === 'claim' && h.holder_run === session.run; @@ -1494,6 +1513,7 @@ export class Broker { } disconnect(cap) { this.#sessions.delete(cap); + this.#pages.delete(cap); } identity(cap) { const s = this.#session(cap); @@ -4758,12 +4778,21 @@ 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 (request.verb === 'grants.scope' || request.verb === 'grants.scope.set') return this.#grantScopeVerb(s, request.verb, structuredClone(args)); - if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) { + 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)) { // 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. @@ -4809,6 +4838,12 @@ 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 @@ -5263,7 +5298,14 @@ 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); + 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 { // 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 }); @@ -5277,6 +5319,78 @@ 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, request, 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) { @@ -5360,6 +5474,7 @@ 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 @@ -5486,7 +5601,7 @@ export class Broker { by, ); this.#routeSession(s.session); - return { role: s.role, run: s.run, thread: s.thread }; + return { role: s.role, run: s.run, thread: s.thread, ...(this.#libraryProject(s.business) ? { library: true } : {}) }; } if ( this.#store.get( @@ -5510,7 +5625,8 @@ export class Broker { by, ); this.#routeWaiting(s.business, s.role); - return { role: s.role, run: s.run }; + // 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 } : {}) }; } case 'role.release': { keys(a, []); @@ -5874,6 +5990,10 @@ 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 && ![ @@ -5920,6 +6040,9 @@ 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) @@ -6632,11 +6755,15 @@ 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 diff --git a/packages/bus/src/client.mjs b/packages/bus/src/client.mjs index 2ef4d3991..6e06e771e 100644 --- a/packages/bus/src/client.mjs +++ b/packages/bus/src/client.mjs @@ -41,7 +41,8 @@ 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)) - end(new BusError(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)); else end(new BusError('invalid-response')); } catch { end(new BusError('invalid-response')); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 366fd8520..57e64055f 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -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 } from '../../business/src/organization.mjs'; +import { organizationWriter, OrganizationError, setReplyLimits } 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'; @@ -305,6 +305,13 @@ export async function startBroker({ 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 }), @@ -338,6 +345,8 @@ export async function startBroker({ else if(verb==='veto.set')result=instances.veto(id,args,actor); // Row 298: the human's grant scope, through the instance writer. else if(verb==='grants.scope.set')result=instances.grantScope(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}); diff --git a/packages/bus/src/server.mjs b/packages/bus/src/server.mjs index e2dfe0619..c5fdbda35 100644 --- a/packages/bus/src/server.mjs +++ b/packages/bus/src/server.mjs @@ -7,6 +7,11 @@ 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 @@ -15,6 +20,7 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('. // 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); @@ -96,7 +102,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({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }), + (e) => finish(refusal(e, 'adapter-failed')), ) .finally(() => { if (own) release(); @@ -114,7 +120,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({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }), + (e) => finish(refusal(e, 'web-failed')), ) .finally(() => { if (own) release(); @@ -130,7 +136,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({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }), + (e) => finish(refusal(e, 'host-failed')), ) .finally(() => { if (own) release(); @@ -153,14 +159,26 @@ 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({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); }, + e => { if (!socket.destroyed) finish(refusal(e, '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({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); + finish(refusal(e, 'invalid-envelope')); } finally { if (transient) release(); } diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs index d5ed846e5..c5b1d81c7 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -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"]; +const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits"]; // The config directory: next to the system config, $MOSAIC_CONFIG or // ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses. diff --git a/packages/business/src/organization.mjs b/packages/business/src/organization.mjs index c09dcf380..9a6d390cf 100644 --- a/packages/business/src/organization.mjs +++ b/packages/business/src/organization.mjs @@ -8,6 +8,7 @@ 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) { @@ -80,6 +81,33 @@ 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"); @@ -93,6 +121,19 @@ 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); @@ -198,6 +239,7 @@ 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 @@ -205,6 +247,7 @@ 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 }); if (beforeWrite) { beforeWrite(); @@ -260,5 +303,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { }); } - return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) }; + return { logFile, read, readRaw, write, record, recover: id => withLock(id, () => recover(id)) }; } diff --git a/packages/cli/src/limits.mjs b/packages/cli/src/limits.mjs index a442b06f5..48359ff22 100644 --- a/packages/cli/src/limits.mjs +++ b/packages/cli/src/limits.mjs @@ -7,9 +7,10 @@ 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] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " + - "PATCH keys: revision, limits, models, instances, reset, assistants"; + "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"; export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else."; @@ -67,8 +68,9 @@ export async function limitsCommand(args, io, callFor) { io.stdout.write(JSON.stringify(converted, null, 2) + "\n"); return; } - if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] || - (verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) || + const fileVerb = verb === "set" || verb === "replies"; + if (!["view", "set", "reset", "replies"].includes(verb) || values["--models"] || values["--roles-dir"] || + (!fileVerb && values["--file"]) || (fileVerb && !values["--file"]) || (verb === "reset" && parsed.positional.length < 2)) { throw new CliError(LIMITS_USAGE, 4); } @@ -78,6 +80,11 @@ 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)] }); diff --git a/packages/library/src/guard.mjs b/packages/library/src/guard.mjs new file mode 100644 index 000000000..dc4a83a43 --- /dev/null +++ b/packages/library/src/guard.mjs @@ -0,0 +1,197 @@ +// 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'; + +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)', +}); + +// 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 page = web.pages.find(p => p.url === c.url || p.finalUrl === c.url); + if (!page) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`); + if (!quote || !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, ['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, ['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 }); +} diff --git a/packages/library/src/index.mjs b/packages/library/src/index.mjs index 65bb898bd..e9f8c8cae 100644 --- a/packages/library/src/index.mjs +++ b/packages/library/src/index.mjs @@ -11,3 +11,5 @@ export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommi export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.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'; +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'; diff --git a/packages/library/src/limits.mjs b/packages/library/src/limits.mjs new file mode 100644 index 000000000..3b017bda4 --- /dev/null +++ b/packages/library/src/limits.mjs @@ -0,0 +1,41 @@ +// 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); +} +