diff --git a/src/opendox/web/app.js b/src/opendox/web/app.js index d6f5c740..935743d3 100644 --- a/src/opendox/web/app.js +++ b/src/opendox/web/app.js @@ -73,7 +73,7 @@ import { mountStagingWorkbench } from "./views/staging-workbench.js"; // resolved in `render()` below, off the `gate.workbench.session` binding, and // `refusalTransport` is the fallback. import { createModelIntakeTransports } from "./views/swb-model-intake.js"; -import { CONSOLE_TOKEN_FIELD } from "./views/staging-workbench-model.js"; +import { CONSOLE_TOKEN_FIELD, GATELESS_SAVE_REFUSAL } from "./views/staging-workbench-model.js"; // THE TWO MODELS THE CONTRIBUTED GATE COLUMN REACHES THROUGH `ctx` — RULED // counterpart Q6 (opensoft/openxFactory#656 comment `5649094228`, Brett Heap, // 2026-09-12): "what a CONTRIBUTED view module may IMPORT from openDox's @@ -952,14 +952,20 @@ function ensurePageOverlayHost(doc) { // column has no transport at all, and a Save that silently did nothing would be // the worst answer available. This stands in for it and REFUSES in the shape // `runSave` already understands, naming the layering rather than the symptom. +// +// SINCE T102 THIS IS EVERY STANDALONE SAVE (RULED openxFactory#656 +// `5963618568`: "only creating documents and Save stay behind the gate, so Save +// is refused by name"). A standalone workbench now offers its editors by scope, +// so a human really does press Save here, and the refusal is the model's one +// sentence: what is missing and which binding would supply it. It is picked on +// the session half alone, so the sentence names only that half. It used to end +// "Run the CLI verb in your pinned checkout", a remedy a standalone install +// does not have. function refusalTransport() { return async () => ({ ok: false, error: "no_gate_column", - message: "this shell was assembled without the column that contributes the " - + "first-edit transport (`gate.workbench.session`), so a governed Save " - + "cannot be sent. Run the CLI verb in your pinned checkout, where the " - + "authority lives.", + message: GATELESS_SAVE_REFUSAL, }); } diff --git a/src/opendox/web/index.html b/src/opendox/web/index.html index cc41fc86..6ab758c9 100644 --- a/src/opendox/web/index.html +++ b/src/opendox/web/index.html @@ -104,7 +104,7 @@

- A read-only projection of one repository's ideation governance state, + A projection of one repository's Markdown files and how they group, rendered from a single generated snapshot.json. The funnel reads the snapshot's link tallies and readiness verbatim — it never rescans the repository or re-scores anything. Regenerate diff --git a/src/opendox/web/views/doc-wheel.js b/src/opendox/web/views/doc-wheel.js index 89f71c32..07321eb9 100644 --- a/src/opendox/web/views/doc-wheel.js +++ b/src/opendox/web/views/doc-wheel.js @@ -99,6 +99,13 @@ const SAVE_UNAVAILABLE = const SAVE_CONTEXT_ONLY = "this document is read-only context in the opened tile, not the tile's own " + "material, so it is not offered to the governed Save"; +// T102 (RULED openxFactory#656 `5963618568`): where editing is offered BY +// SCOPE, the `edit` verb is offered on exactly the documents the scope lets be +// edited, and a document outside that set states why rather than failing when +// pressed. Under the gate no entry carries `editable`, so this never shows. +const LOAD_NOT_THE_TILES_OWN = + "this document is context in the opened tile, not one of the tile's own, " + + "so it is not offered for editing here"; const SAVE_NOT_LOADED = "load this document for editing before saving it"; const SAVE_NOTHING_TO_DO = @@ -345,7 +352,13 @@ export function renderDocWheel(host, entries, opts = {}) { // answer wins when one exists (design D2): a context-only document is // LOADABLE for grounding and conversation, and its buffer carries // `owned: false`, which the governed Save already withholds. - const owned = live && typeof live.owned === "boolean" + // + // T102: a LIVE answer is a loaded buffer's. The shell answers an unloaded + // path with a placeholder (`owned: true`, `loaded: false`), which used to + // win here, so a by-scope tile outside the scope read "load this document + // for editing before saving it" beside an `edit` verb it does not offer. + // Under the gate no entry carries `owned`, so this reads as before. + const owned = live && live.loaded && typeof live.owned === "boolean" ? live.owned : entry.owned !== false; @@ -369,7 +382,7 @@ export function renderDocWheel(host, entries, opts = {}) { // route in (design D5). Labelled with Brett's own word. const load = el("button", "swb-docload", loaded ? "loaded" : "edit"); load.type = "button"; - if (onLoad && entry.resolved) { + if (onLoad && entry.resolved && entry.editable !== false) { load.title = loaded ? entry.path + " is loaded for editing — selecting it here brings the " + "chat and the canvas back to it" @@ -380,7 +393,9 @@ export function renderDocWheel(host, entries, opts = {}) { }); } else { load.disabled = true; - load.title = entry.resolved ? LOAD_UNAVAILABLE : NOT_CATALOGUED; + load.title = !entry.resolved ? NOT_CATALOGUED + : (onLoad && entry.editable === false + ? LOAD_NOT_THE_TILES_OWN : LOAD_UNAVAILABLE); } row.appendChild(load); diff --git a/src/opendox/web/views/lens.js b/src/opendox/web/views/lens.js index 3075e4ad..c27a5434 100644 --- a/src/opendox/web/views/lens.js +++ b/src/opendox/web/views/lens.js @@ -26,7 +26,7 @@ import { buildLensModel, docSummaries, railStats, termMatches, - savePlan, clusterPlan, WORKBENCH_DIR, + savePlan, clusterPlan, } from "./lens-model.js"; // The SVG bullseye renderer lives in ONE place (add-workbench-bullseye-and-create // design D1): this view and the staging workbench's lens panel consume the same @@ -267,9 +267,13 @@ function renderPlan(container, plan, opts) { if (gateLive(o.caps) && o.mountLensGate) { o.mountLensGate(box, { plan, caps: o.caps, fetcher: o.fetcher }); } else { + // NEUTRAL WORDS (plan 034 T102, gap G8): this note used to name the engine + // module (`lens.py`) and "the boundary", which are the build's words and + // not a reader's. What it says is the same fact: here the plan is shown, + // and nothing carries it out. box.appendChild(el("div", "dc-note", - "The tested engine (lens.py) materialises this through the boundary; the " + - "read-only surface confirms the plan — nothing is written from the browser.")); + "Shown for confirmation only: this console cannot carry the plan out " + + "from the browser, so nothing is written.")); } container.appendChild(box); } @@ -1533,9 +1537,14 @@ export function renderLens(root, snapshot, opts) { }); box.appendChild(save); box.appendChild(cluster); + // NEUTRAL WORDS (plan 034 T102, gap G8): this line named a host's + // surfaces -- the workbench folder, "the register", "any queue" -- which + // a standalone install does not have. What stays true everywhere is what + // the two buttons do: each shows its plan first, and the pane itself + // writes nothing. Where the plan lands is the plan's own `lands at` line. box.appendChild(el("div", "lens-pending", - "Persisted through the interactivity boundary to " + WORKBENCH_DIR + - " (gitignored). Nothing enters the register or any queue from here.")); + "Each button shows its plan below before anything is written; this " + + "pane itself writes nothing.")); pane.appendChild(box); }, setName() { diff --git a/src/opendox/web/views/staging-workbench-model.js b/src/opendox/web/views/staging-workbench-model.js index 423e129b..7daa90a5 100644 --- a/src/opendox/web/views/staging-workbench-model.js +++ b/src/opendox/web/views/staging-workbench-model.js @@ -403,6 +403,14 @@ export function doxbenchScopeProjection(snapshot, kind, id, options = {}) { const scope = workbenchScope(snapshot, kind, id); if (!scope) return null; + // T102: `editableBy: "tile"` is openDox's OWN scope authority, the one a + // standalone install's server answers with (`default_columns.resolve_scope`, + // RULED `5961651355`): the tile's own documents, editable, and nothing else + // in context. Absent, which is every governed host, the projection is + // byte-for-byte what it was. + const byTile = options.editableBy === "tile"; + const tileOwn = byTile ? tileOwnScope(snapshot, kind, id) : null; + const contextPaths = []; const contextSeen = new Set(); const pushContext = (path) => { @@ -411,24 +419,43 @@ export function doxbenchScopeProjection(snapshot, kind, id, options = {}) { contextSeen.add(value); contextPaths.push(value); }; - for (const section of scope.sections || []) { - for (const row of section.documents || []) { - if (row.resolved) pushContext(row.path); + if (byTile) { + // the neutral scope's context IS its own sections' resolved rows, which + // are exactly the editable set, then the settings documents the tile + // names, readable in a trailing section nothing owns (M1) + for (const path of tileOwn.editable) pushContext(path); + for (const path of tileOwn.settings) pushContext(path); + } else { + for (const section of scope.sections || []) { + for (const row of section.documents || []) { + if (row.resolved) pushContext(row.path); + } } + // FR-043 (T107): a session-created document is CONTEXT as well as + // editable, so it appears here too — appended AFTER the sections, which is + // where the server appends it (`doxbench_scope._projection`), because the + // two derivations are compared as ordered lists on the shared fixture. + // `createdDocuments` is the page's own overlay and this remains + // PRESENTATION: the server derives its own created record from the session + // worktree and never reads this one. + for (const path of options.createdDocuments || []) pushContext(path); } - // FR-043 (T107): a session-created document is CONTEXT as well as editable, so - // it appears here too — appended AFTER the sections, which is where the server - // appends it (`doxbench_scope._projection`), because the two derivations are - // compared as ordered lists on the shared fixture. `createdDocuments` is the - // page's own overlay and this remains PRESENTATION: the server derives its own - // created record from the session worktree and never reads this one. - for (const path of options.createdDocuments || []) pushContext(path); + // NO OUTLINE BY TILE (Copilot review of #81, r4173502649). The neutral + // scope projects no outline at all (`resolve_scope` returns + // `outline_path=None`), and the turn's binding check requires the outline + // buffer's path to EQUAL the projection's (`doxbench_turns. + // _require_buffer_binding`). Deriving one here from the richer workbench + // scope kept a staged tile's primary file as the outline, and the server + // refused every chat turn from that tile. By tile the file is simply one of + // the tile's own documents, loadable like the rest. let outlinePath = null; - if (scope.outline && typeof options.outlinePathFor === "function") { + if (!byTile && scope.outline && typeof options.outlinePathFor === "function") { outlinePath = asId(options.outlinePathFor(scope.outline)) || null; } - const editablePaths = rewritableDocuments(scope, options.createdDocuments || []); + const editablePaths = byTile + ? [...tileOwn.editable] + : rewritableDocuments(scope, options.createdDocuments || []); const editableSeen = new Set(editablePaths); // T104 F2, byte-for-byte the server's own rule (doxbench_scope._projection -- // the two derivations are compared as ordered lists on the shared fixture): @@ -1465,6 +1492,265 @@ export function notebookRefreshCommand(opts) { return parts.join(" "); } +// ---- EDITING BY SCOPE (plan 034 T102) ------------------------------------- +// +// RULED by Brett Heap, openxFactory#656 comment `5963618568`, "Edit and chat by +// scope (Recommended)": the editors and the chat rail appear WHEREVER THE SCOPE +// LETS THE DOCUMENT BE EDITED, and only creating documents and Save stay behind +// the gate, so Save is refused by name. It builds on `5961651355`, "Tile's own +// documents editable (Recommended)": openDox's own neutral scope default marks +// a tile's OWN documents editable. +// +// What it replaces. The canvas, the rail, the docs tile's `edit` verb and the +// outline's add-section seam were ALL gated on the create column's +// `createGateLive(caps)`, and only openXdox's `gate.workbench.create` binding +// answers that. A standalone install registers no such column, so every +// workbench it opened was read-only, though its `/capabilities` reads +// `edit: true` and its own scope makes the tile's documents editable. +// +// So editing is now asked of the two signals a standalone install HAS: the +// `edit` capability (`/capabilities` `actions.edit`, the serving process's own +// loopback-human verdict) and the per-document editability the scope answers. +// The gate keeps what is the gate's: creating a document, and Save. +// +// A GOVERNED HOST IS UNTOUCHED, by construction rather than by care. Where a +// host's gate column is registered, the gate decides the whole editing posture +// exactly as before (`governed` below): the by-scope arm is openDox's own +// default, answered only where no column is. + +//: The modes `editingPosture` answers, as data a caller can compare against. +export const EDITING_MODES = Object.freeze({ + // the host's create gate is live: editing, create and Save, as before + gate: "gate", + // no gate column: editing and chat by scope; create absent, Save refused + scope: "scope", + // no gate column, the edit capability live, and nothing editable in scope + nothing: "nothing-editable", + // no editing at all on this console + readOnly: "read-only", + // the hosted plane, which shows no editing surface whatever it claims + hidden: "hidden", +}); + +// A path the neutral scope can name safely, or null. This is +// `default_columns._canonical`'s rule, which refuses the WHOLE tile when any +// one of its paths fails it (it raises `ScopeConfinementError`), so the caller +// below answers "nothing editable" for that tile rather than a partial set the +// server would never agree to. +function canonicalScopePath(raw) { + if (typeof raw !== "string" || !raw || raw.includes("\u0000") + || raw.includes("\\") || raw.startsWith("/")) { + return null; + } + const parts = raw.split("/"); + if (parts.some((part) => part === "" || part === "." || part === "..")) { + return null; + } + return raw; +} + +function scopeText(value) { + return typeof value === "string" ? value.trim() : ""; +} + +// OPENDOX'S OWN SETTINGS DOCUMENTS (T084's adversarial review 2, M1), which +// the server never lets a tile edit: `default_columns.SETTINGS_DOCUMENTS`, +// the model-provider bindings (`doxbench_binding.DEFAULT_BINDINGS_RELPATH`) +// and the model declarations (`doxbench_intake.DEFAULT_DECLARATIONS_RELPATH`). +// A tile that names one keeps it READABLE, in a trailing section nothing owns, +// and never editable. Spelled here because this module imports nothing from +// the server; the parity test holds the two spellings equal. +export const OWN_SETTINGS_DOCUMENTS = Object.freeze([ + "ideation/dashboard/model-provider-bindings.yaml", + "ideation/dashboard/model-declarations.yaml", +]); + +// THE TILE'S OWN DOCUMENTS, as openDox's neutral scope default answers them. +// +// This MIRRORS `opendox.default_columns.resolve_scope` and `editable_paths`, +// read off the same snapshot fields in the same order: a group's +// `document_edges`, a selection's `files`, and the members of the groups that +// claim a candidate. Each reference is resolved to its document's path in the +// namespace it is written in (`_document_index`: an edge by id, a selection's +// file by path), deduplicated and kept in order, and an +// unknown tile, an unknown group or a path the scope cannot name answers what +// the server answers. A node-and-Python parity test holds the two together +// (`tests/test_workbench_edit_by_scope.py`). +// +// It is PRESENTATION, like every projection in this module: the server decides +// for every turn and every Save from its own rule. One fact the browser cannot +// see is whether a catalogued path still RESOLVES inside the checkout (the +// server's `resolve_within`); a document deleted since the snapshot was taken +// is therefore offered here and refused there, by the server's own scope +// refusal. The same holds for an in-root SYMLINK that reaches a settings +// document (T084 fix round 5): the server compares the file a row resolves to +// and moves the alias into the settings section, and the browser, which sees +// only spellings, offers it until the server refuses it. +export function tileOwnEditablePaths(snapshot, kind, id) { + return tileOwnScope(snapshot, kind, id).editable; +} + +// The neutral projection's two path lists, as `resolve_scope` builds them: +// `editable`, the tile's own documents; and `settings`, the resolved settings +// documents the tile names, which trail the context and are never editable. +function tileOwnScope(snapshot, kind, id) { + const none = { editable: [], settings: [] }; + const s = snapshot || {}; + const wanted = asId(id); + if (!wanted) return none; + // `_document_index`: each listed document's PATH, in TWO namespaces kept + // apart (T084 fix rounds 3 and 4). A group's edges name a document by ID and + // a selection's files by PATH, and the two need not be equal. `ids` answers + // an id first and then, for an edge written as a path, a path: an id is + // indexed first, so a path that equals another document's id cannot take + // its place. `paths` answers a path only, so a selection's file `x` never + // resolves to the document whose ID is `x`. + const ids = new Map(); + const paths = new Map(); + const listed = (Array.isArray(s.documents) ? s.documents : []) + .map((d) => (d && typeof d === "object" ? d : {})); + for (const field of ["id", "path"]) { + for (const d of listed) { + const key = scopeText(d[field]); + const path = scopeText(d.path); + if (key && path && !ids.has(key)) ids.set(key, path); + } + } + for (const d of listed) { + const path = scopeText(d.path); + if (path && !paths.has(path)) paths.set(path, path); + } + let index = ids; + // `{_text(g.get("id")): g}` on the server: a repeated id keeps the last. + const groups = new Map(); + for (const g of Array.isArray(s.clusters) ? s.clusters : []) { + groups.set(scopeText(g && g.id), g && typeof g === "object" ? g : {}); + } + const members = (group) => (Array.isArray(group && group.document_edges) + ? group.document_edges : []) + .map((edge) => (edge && typeof edge === "object" ? edge.document : undefined)); + let refs = null; + if (kind === SCOPE_KINDS.grouping) { + if (!groups.has(wanted)) return none; + refs = members(groups.get(wanted)); + } else if (kind === SCOPE_KINDS.selection) { + const selection = (Array.isArray(s.staged_topics) ? s.staged_topics : []) + .find((t) => scopeText(t && t.staging_id) === wanted); + if (!selection) return none; + refs = Array.isArray(selection.files) ? selection.files : []; + index = paths; + } else if (kind === SCOPE_KINDS.candidate) { + const candidate = (Array.isArray(s.possibles) ? s.possibles : []) + .find((p) => scopeText(p && p.id) === wanted); + if (!candidate) return none; + refs = []; + for (const groupId of Array.isArray(candidate.claiming_clusters) + ? candidate.claiming_clusters : []) { + refs.push(...members(groups.get(scopeText(groupId)) || {})); + } + } else { + return none; + } + const editable = []; + const settings = []; + const seen = new Set(); + for (const raw of refs) { + // the document the reference names, by its path; an unlisted reference + // stays under its own spelling, unresolved, and must still be nameable + const found = typeof raw === "string" ? index.get(scopeText(raw)) : undefined; + const path = canonicalScopePath(found !== undefined ? found : raw); + if (path === null) return none; // the server refuses the whole tile + if (seen.has(path)) continue; + seen.add(path); + if (found === undefined) continue; + (OWN_SETTINGS_DOCUMENTS.includes(path) ? settings : editable).push(path); + } + return { editable, settings }; +} + +// THE ONE ANSWER TO "MAY THIS SURFACE EDIT, AND BY WHOSE AUTHORITY". Pure: +// facts in, a frozen posture out. +// +// governed a host's gate column is registered (`gate.workbench.create`) +// gateLive that column's `createGateLive(caps)`; never true without one +// surfaceHidden `sessionSurfaceHidden(caps)`, the hosted plane +// editLive `/capabilities` `actions.edit` +// editablePaths the scope's own answer, `tileOwnEditablePaths(...)` +// +// The answer's `editors` drives the canvas, the rail, the docs tile's `edit` +// verb and the add-section seam; `create` drives every create affordance and +// the document abstract's generation (ruling 7.7 keys it on the gate); `save` +// says what Save does: `gate` (the governed Save), `refused` (present, and +// refused by name), or `absent` (there is no canvas to save from). +export function editingPosture(input) { + const f = input || {}; + const editable = Array.isArray(f.editablePaths) + ? f.editablePaths.filter((path) => typeof path === "string" && path) : []; + const answer = (mode, editors, create, save, paths) => Object.freeze({ + mode, editors, create, save, + editablePaths: paths === null ? null : Object.freeze([...paths]), + }); + if (f.surfaceHidden) { + return answer(EDITING_MODES.hidden, false, false, "absent", []); + } + // THE GATE, exactly as before: where it is live it decides everything, and + // every document the tile carries is loadable, as it always was. + if (f.gateLive) { + return answer(EDITING_MODES.gate, true, true, "gate", null); + } + // A HOST'S COLUMN IS REGISTERED AND ITS GATE IS OFF: read-only, as before. + // The by-scope arm is openDox's own default and never a host's. + if (f.governed || f.editLive !== true) { + return answer(EDITING_MODES.readOnly, false, false, "absent", []); + } + if (!editable.length) { + return answer(EDITING_MODES.nothing, false, false, "absent", []); + } + return answer(EDITING_MODES.scope, true, false, "refused", editable); +} + +// Whether ONE document of the tile may be loaded for editing under `posture`. +// Under the gate every document the tile carries is, as it always was (the +// governed Save withholds context-only material itself); by scope, exactly the +// documents the scope answers editable; otherwise none. +export function documentEditable(posture, path) { + if (!posture || !posture.editors) return false; + if (posture.mode === EDITING_MODES.gate) return true; + return Array.isArray(posture.editablePaths) + && posture.editablePaths.includes(asId(path)); +} + +// WHAT A STANDALONE SAVE ANSWERS, by name. `app.js`'s `refusalTransport` is +// the Save transport where no host contributes `gate.workbench.session`, which +// is every standalone install, and this is the sentence its refusal carries: +// what is missing, which binding would supply it, and what happened to the +// human's text. It stood there as "run the CLI verb in your pinned checkout", +// a remedy a standalone install does not have. +// It names ONLY the missing session transport (Copilot review of #81, +// r4174293950): `app.js` picks this fallback on the session half alone, so a +// host that registers a live create column but no session column reaches it +// too, and there "this install has no create gate" would be false. +export const GATELESS_SAVE_REFUSAL = + "Save needs the first-edit transport, and no column on this install " + + "contributes it (`gate.workbench.session`), so a governed Save cannot be " + + "sent. Your edits stay in this browser's buffers, unsaved."; + +// The plane note the by-scope posture stands on, beside whatever the chat rung +// says on the send button. Written through the facet: the tile's own items are +// the registered domain's word. +export function scopeEditingNote() { + return "editing by scope: this tile's own " + vocab.many(SOURCE) + + " are editable here, and creating one and Save need the create gate, " + + "which this install does not have, so Save is refused by name and " + + "nothing is written."; +} + +export function nothingEditableNote() { + return "read-only: this tile has none of its own " + vocab.many(SOURCE) + + " to edit here, so editing and chat are not offered — retained context " + + "stays readable."; +} + // ---- the presentation posture (010-doxbench-editor-chat T090) ----------- // // ONE derivation names the posture the presentation is in, so omitting a @@ -1480,8 +1766,26 @@ export function notebookRefreshCommand(opts) { // lands (T005-T008 / T052-T054) — so a capable local console today states // that chat is unavailable while both editors stay usable (FR-025, US5 // acceptance scenario 2), which is the truth of this build. +// +// T102 ADDS ONE FACT, `editing` (`editingPosture(...).mode`), and changes +// nothing for a caller that does not pass it, which is every governed host: +// there the gate rung below answers exactly as it did. With the gate off and +// `editing` the by-scope mode, the gate rung stands aside and the ladder goes +// on down; every posture it then reaches that offers the canvas also carries +// `editing: "scope"` and the `scopeNote` the shell states beside it, because +// "Save is refused by name" is a fact about the PLANE and is not what any chat +// rung says. With the gate off and nothing editable in scope, the gate rung +// says so in those words rather than blaming a gate. export function presentationPosture(input) { const facts = input || {}; + const byScope = !facts.gateLive && facts.editing === EDITING_MODES.scope; + const posture = postureLadder(facts, byScope); + return byScope && posture.canvas + ? { ...posture, editing: EDITING_MODES.scope, scopeNote: scopeEditingNote() } + : posture; +} + +function postureLadder(facts, byScope) { const gateLive = !!facts.gateLive; const surfaceHidden = !!facts.surfaceHidden; const keyed = !!facts.repository && !!facts.ref; @@ -1496,7 +1800,10 @@ export function presentationPosture(input) { + ", and " + TAB_IDS.outline + " stay readable.", }; } - if (!gateLive) { + if (!gateLive && !byScope && facts.editing === EDITING_MODES.nothing) { + return { kind: "nothing-editable", canvas: false, note: nothingEditableNote() }; + } + if (!gateLive && !byScope) { return { kind: "gate-off", canvas: false, note: "read-only: the create/edit gate is off here, so editing, chat, " + @@ -1972,11 +2279,19 @@ export function abstractRegionState(input) { // it was inherited, and the wheel renders that on the tile's sub-line — the slot // where the deck shows a link degree. Nothing the headings said is lost; it just // travels with the tile instead of sitting above a group of them. -export function docWheelEntries(scope) { +// +// `options.editable` (T102) is the by-scope posture's editable set. Given, each +// entry also says whether IT is editable and owned, so the tile can offer its +// `edit` verb on exactly the documents the scope lets be edited and state the +// absence on the rest. Absent, which is the gate's posture, the entries are +// byte-for-byte what they were. +export function docWheelEntries(scope, options = {}) { + const editable = Array.isArray(options && options.editable) + ? new Set(options.editable) : null; const out = []; for (const section of scope?.sections || []) { for (const row of section.documents || []) { - out.push(docWheelEntry(section, row)); + out.push(docWheelEntry(section, row, editable)); } } return out; @@ -1985,10 +2300,13 @@ export function docWheelEntries(scope) { // ONE tile's worth of it. Split out of the loops above so each half stays // readable: the loops are about flattening sections, this is about what a // single document contributes. -function docWheelEntry(section, row) { +function docWheelEntry(section, row, editable) { const path = typeof row.path === "string" ? row.path : ""; const score = row.completeness?.score; + const byScope = editable === null ? {} + : { editable: editable.has(path), owned: editable.has(path) }; return Object.freeze({ + ...byScope, path, label: path ? path.split("/").pop() : "(unnamed)", section: section.label || "", diff --git a/src/opendox/web/views/staging-workbench.js b/src/opendox/web/views/staging-workbench.js index 4e41a41f..8c1238a9 100644 --- a/src/opendox/web/views/staging-workbench.js +++ b/src/opendox/web/views/staging-workbench.js @@ -79,6 +79,7 @@ import { workbenchScope, doxbenchScopeProjection, lensScopeSnapshot, lensSessionSeed, toggleKeyword, createSeed, createOffered, rewritableDocuments, sessionPosture, sessionSurfaceHidden, presentationPosture, + EDITING_MODES, editingPosture, documentEditable, tileOwnEditablePaths, documentAbstract, docWheelEntries, existingOnTopic, abstractRegionState, abstractSubjectDigest, setDisplay, } from "./staging-workbench-model.js"; @@ -870,8 +871,11 @@ function renderDocsPanel(pane, scope, onOpen, create, verbs, abstractSeam) { session.repaint = paint; // A drum is one reel, so the sections flatten — carrying their labels onto - // the tiles rather than losing them (see `docWheelEntries`). - const entries = docWheelEntries(scope); + // the tiles rather than losing them (see `docWheelEntries`). By scope (T102) + // the verbs carry the scope's editable set, and each tile then says whether + // it is one of the tile's own; under the gate they carry none, as before. + const entries = docWheelEntries(scope, + verbs && Array.isArray(verbs.editable) ? { editable: verbs.editable } : {}); let seeded = false; // PR #196 review F4: while the wheel is being RECONCILED to the document the // canvas actually holds, its own onSelect must not turn round and ask for @@ -1337,12 +1341,19 @@ async function runAddSection(seam, path, note, title, after, required) { // authority teaches the reader nothing, but there is no write path to reach — // the same posture viewer.js's "open in editor" button and the docs tile's // load/save verbs already take where the capability is absent. -function mountAddSection(host, model, path, seam) { +// +// T102: `seam` may also be `{ absent: sentence }`, a stated absence with its +// own reason. By scope there IS an editing capability but no outline buffer +// (the neutral scope projects none), so the generic sentence below would be +// false there. +function mountAddSection(host, model, path, given) { const note = el("div", "swb-outlinenote"); note.setAttribute("aria-live", "polite"); note.hidden = true; - const absent = "adding a section needs the local human console's editing " - + "capability — this plane has none, so no write path is offered"; + const seam = given && typeof given.absent === "string" ? null : given; + const absent = given && typeof given.absent === "string" ? given.absent + : "adding a section needs the local human console's editing " + + "capability — this plane has none, so no write path is offered"; // `intent()` is read at CLICK time, never closed over at build time: the // free-form control's two values are whatever the human has typed and chosen @@ -1615,6 +1626,18 @@ export function mountStagingWorkbench(container, snapshot, // workbenches must not share a column (Copilot review, round 2). const createColumn = gate?.create || NO_CREATE_COLUMN; const sessionColumn = gate?.session || NO_SESSION_COLUMN; + // A HOST'S GATE COLUMN IS REGISTERED (T102). Where one is, its gate decides + // the whole editing posture exactly as it always did; where none is, which + // is every standalone install, openDox's own default answers editing BY + // SCOPE (RULED `5963618568`). See `editingPosture` in the model. + // EITHER HALF counts (Copilot review of #81, r4174293974): the two bindings + // resolve independently, and `app.js` sends Save through a contributed + // session column's `firstEditTransport` whenever one exists. A host that + // registers only that half is therefore governed, so its Save is not the + // by-scope refusal; with no create column its gate reads off, so it stays + // read-only exactly as before T102. + const governed = createColumn !== NO_CREATE_COLUMN + || sessionColumn !== NO_SESSION_COLUMN; // THE VOCABULARY, INSTALLED BEFORE ANYTHING RENDERS (slice S7) — into this // module AND into the pure model it derives through, which is why the model // exports `setDisplay` rather than taking the facet on every signature. @@ -1652,6 +1675,19 @@ export function mountStagingWorkbench(container, snapshot, ") — it never edits or deletes an existing document" : "doxBench writes nothing here — every create affordance is a " + "copyable CLI descriptor on this host"; + const readonlyTitle = readonly.title; + // THE PILL FOLLOWS THE SCOPE where the gate does not decide (T102). Under + // the gate it is the constant above, exactly as before; by scope it states + // the posture the open tile is in, so it is redrawn with the canvas. + function drawPill(byScope) { + if (gateOn) return; + readonly.textContent = byScope ? "editing by scope" : "read-only"; + readonly.title = byScope + ? "this tile's own " + vocab.many(SOURCE) + " are editable in this " + + "browser; creating one and Save need the create gate, which this " + + "install does not have, so Save is refused by name" + : readonlyTitle; + } const closeBtn = el("button", "swb-close", "✕ back to the wheel"); closeBtn.type = "button"; // FULL SCREEN: a class on the overlay, not the Fullscreen API — the panel is @@ -2254,6 +2290,9 @@ export function mountStagingWorkbench(container, snapshot, ref: active.ref, outlinePathFor: (outline) => primaryFragmentPath(outline.stagingId, outline.files), createdDocuments: sessionColumn.createdDocuments(posture.branch), + // T102: with no host's gate column the server's scope authority is + // openDox's own default, so the projection says what THAT answers + ...(governed ? {} : { editableBy: "tile" }), }); } function railScopeKey() { @@ -2347,7 +2386,14 @@ export function mountStagingWorkbench(container, snapshot, if (!canvasOffered()) { return { bufferStateFor: () => null }; } + // BY SCOPE (T102) THE VERB IS PER DOCUMENT: the scope's editable set rides + // the verbs, so the tile offers `edit` on exactly the documents the scope + // lets be edited and states the absence on the rest. Under the gate there + // is no such set and every document stays loadable, as it always was. + const editing = editingNow(); + const byScope = editing.mode === EDITING_MODES.scope; return { + ...(byScope ? { editable: editing.editablePaths } : {}), load: async (path) => { // Re-checked at CLICK time, not at render time: the docs pane is drawn // before the canvas mounts, so a human who clicks before the mount @@ -2357,6 +2403,14 @@ export function mountStagingWorkbench(container, snapshot, return { ok: false, error: "this console has no editing seam wired" }; } + // …and the scope's answer is re-read at click time too, as defence in + // depth behind the tile's own disabled verb + if (!documentEditable(editingNow(), path)) { + return { ok: false, + error: "this document is context in the opened tile, not " + + "one of the tile's own, so it is not offered for " + + "editing here" }; + } // TWO ROUTES, and which one applies is a fact about the RESERVED SLOT. // // While that slot is UNBACKED — a scope whose projection published no @@ -2478,7 +2532,14 @@ export function mountStagingWorkbench(container, snapshot, // `canvasOffered()` — the SAME derivation the canvas mount and the tile // verbs use — so the pane can never claim a capability the canvas // withheld, nor deny one it has. - capable: canvasOffered() && wired, + // + // AND THE GATE (T102). Editing by scope offers the canvas without the + // create gate, and ruling 7.7 keys GENERATION on the gate: the route + // refuses at its step 1 wherever `actions.gate` is false. So the control + // stays ABSENT there rather than present-and-refusing. Under the gate + // `canvasOffered()` already implied this conjunct, so a governed host + // reads exactly what it read. + capable: canvasOffered() && createColumn.createGateLive(caps) && wired, // A statement about the PLANE, which outranks any capability it reports. hosted: sessionSurfaceHidden(caps), // COMPOSED AS JSON, NEVER JOINED (adversarial review 2026-08-25, N6; @@ -2548,6 +2609,15 @@ export function mountStagingWorkbench(container, snapshot, // listener bound. function outlineSectionSeam() { if (!canvasOffered()) return null; + // BY SCOPE THERE IS NO OUTLINE BUFFER (T102): the neutral scope projects + // none, so the selection's primary file is one of its own documents, edited + // through its tile's `edit` verb. The add-section controls say so instead + // of offering an insert that would find no outline to land in. + if (editingNow().mode === EDITING_MODES.scope) { + return { absent: "adding a section writes into an outline buffer, and " + + "here this tile's own " + vocab.many(SOURCE) + " are edited directly, " + + "with no outline buffer: open the file with its edit verb instead" }; + } return { actor: (caps && caps.actor) || "local", // Read at CLICK time, never cached: "what the outline buffer holds" is a @@ -2717,17 +2787,88 @@ export function mountStagingWorkbench(container, snapshot, // source-unavailable) keep the inline note: they explain a canvas that is // WITHHELD or degraded, the rail is not even mounted for most of them, and // there is no control to hang the sentence on. + // + // T102: the by-scope posture's `scopeNote` is a PLANE fact too ("Save is + // refused by name"), so it stands beside whatever a chat rung moved onto the + // send button. A governed host's postures carry none, so its note is what it + // was. + let lastPlane = null; function showPostureNote(plane) { + lastPlane = plane; const stands = !!plane.note && plane.chat !== true; - postureNote.textContent = stands ? plane.note : ""; - postureNote.hidden = !stands; + const stale = outOfScopeLoaded(); + const lines = [stands ? plane.note : null, plane.scopeNote || null, + stale.length ? outOfScopeNote(stale) : null].filter(Boolean); + postureNote.textContent = lines.join(" "); + postureNote.hidden = lines.length === 0; + } + + // A RESTORED BUFFER THE SCOPE NO LONGER OWNS (T102; Copilot review of #81, + // r4173470792). The canvas restores a persisted record verbatim, and every + // other route into the loaded set is gated by scope: the tile's `edit` verb, + // and the canvas's own in-scope check. So by scope the RESTORE is the one way + // a document the scope no longer makes editable can still be loaded, for + // example after a regenerated snapshot drops it from the group. Once the + // canvas is ready it is reconciled with the projection it was mounted over: + // * a CLEAN one leaves the loaded set, since nothing of the human's is in it; + // * a DIRTY one stays, because unloading it would discard unsaved text, + // and the posture note names it, says why, and says what to do. A chat + // turn that carries it is refused by the server's own scope check, which + // is why the note asks for it to be unloaded once the text is safe. + // By scope only: under the gate the projection, ownership and restore are + // exactly as before. + function outOfScopeLoaded() { + if (!canvasController || editingNow().mode !== EDITING_MODES.scope) return []; + const live = canvasController.state(); + if (!live || !live.buffers) return []; + const editable = new Set(editingNow().editablePaths || []); + const out = []; + for (const key of Object.keys(live.buffers)) { + const buffer = live.buffers[key]; + if (!buffer || buffer.kind !== "document" || !buffer.path) continue; + if (!editable.has(buffer.path)) out.push({ key, path: buffer.path, + dirty: buffer.dirty === true }); + } + return out; + } + function outOfScopeNote(stale) { + return "restored with unsaved text, but no longer one of this tile's own " + + vocab.many(SOURCE) + ": " + stale.map((b) => b.path).join(", ") + + ". It stays loaded so nothing is lost; copy the text out, then unload " + + "it, because a chat turn that carries it is refused."; + } + function reconcileRestoredScope() { + for (const buffer of outOfScopeLoaded()) { + if (!buffer.dirty) canvasController.unloadDocument(buffer.key); + } + if (lastPlane) showPostureNote(lastPlane); + } + + // THE EDITING POSTURE (T102, RULED `5963618568`), read off the facts this + // console has: a host's gate column and its gate, the hosted plane, the + // `edit` capability, and the open tile's own editable documents as openDox's + // scope default answers them. A governed host asks only its gate. + function editingNow() { + return editingPosture({ + governed, + gateLive: createColumn.createGateLive(caps), + surfaceHidden: sessionSurfaceHidden(caps), + editLive: !!(caps && caps.actions && caps.actions.edit === true), + editablePaths: scope && !governed + ? tileOwnEditablePaths(snapshot, scope.kind, scope.id) : [], + }); } // WHETHER THIS SURFACE OFFERS EDITING AT ALL — one derivation, read by the // canvas mount and by the docs tile's verbs (F3), so the tile can never claim a // capability the canvas withheld or deny one it has. + // + // T102: `editingNow().editors` is `createColumn.createGateLive(caps) && + // !sessionSurfaceHidden(caps)` wherever the gate is live, which is the whole + // of a governed host; where no gate column is registered it is the by-scope + // answer instead. function canvasOffered() { - return !!scope && createColumn.createGateLive(caps) && !sessionSurfaceHidden(caps) + return !!scope && editingNow().editors && !!active?.repository && !!active?.ref; } function drawCanvas() { @@ -2742,8 +2883,12 @@ export function mountStagingWorkbench(container, snapshot, // decision itself is unchanged and stays pinned below; `approvedModelCount` // is LIVE — fed back by the mounted rail's adopted catalog (see the // onState wiring below) and reset to zero on rail teardown. + const editing = editingNow(); const plane = presentationPosture({ gateLive: createColumn.createGateLive(caps), + // T102: the editing posture's mode; a governed host's gate rung reads + // exactly as before, because there it is never the by-scope mode + editing: editing.mode, surfaceHidden: sessionSurfaceHidden(caps), repository: active?.repository, ref: active?.ref, @@ -2754,6 +2899,7 @@ export function mountStagingWorkbench(container, snapshot, }); showPostureNote(plane); const offered = canvasOffered(); + drawPill(offered && editing.mode === EDITING_MODES.scope); canvas.hidden = !offered; if (!offered) return; // the SAME posture derivation drawSession() already uses, so the @@ -2797,6 +2943,8 @@ export function mountStagingWorkbench(container, snapshot, onLoadedSetChanged: () => { syncContextFromCanvas(); refreshDocTiles(); + // an unload of a restored out-of-scope buffer retires its note (T102) + if (lastPlane) showPostureNote(lastPlane); }, }); // T055: the chat rail mounts ONLY when the seam bundle carries BOTH @@ -2878,6 +3026,7 @@ export function mountStagingWorkbench(container, snapshot, postureIntakeOffered = modelIntakeOffered; const refreshed = presentationPosture({ gateLive: createColumn.createGateLive(caps), + editing: editingNow().mode, surfaceHidden: sessionSurfaceHidden(caps), repository: active?.repository, ref: active?.ref, @@ -2971,6 +3120,7 @@ export function mountStagingWorkbench(container, snapshot, const mounted = canvasController; if (mounted && mounted.ready && typeof mounted.ready.then === "function") { mounted.ready.then(() => { + if (canvasController === mounted) reconcileRestoredScope(); if (canvasController === mounted) syncContextFromCanvas(); }); } diff --git a/src/opendox/web/views/wheel.js b/src/opendox/web/views/wheel.js index 36dcfa13..6dab2c2b 100644 --- a/src/opendox/web/views/wheel.js +++ b/src/opendox/web/views/wheel.js @@ -262,10 +262,15 @@ const ACTION_MOUNTERS = { // like read/lens/canvas — no transport of its own, just the app shell's // openWorkbench callback, and DISABLED (never a live no-op) when the shell // supplied no nav callbacks (a bare embedding). + // + // T102: the title said "(read-only)" and named the tile by its SEAM KEY + // (`cluster`, `possible`, `staged`). The workbench now edits wherever the + // scope lets a document be edited (RULED openxFactory#656 `5963618568`), so + // the claim is gone, and the tile is named by the registered domain's word. workbench: (row, item, opts) => mountNavButton(row, { label: opts.label, title: "open the staging workbench scoped to this " + - (WORKBENCH_TILE_KINDS[opts.wheelKey] || "tile") + " (read-only)", + (WORKBENCH_TILE_KINDS[opts.wheelKey] ? vocab.one(opts.wheelKey) : "tile"), run: WORKBENCH_TILE_KINDS[opts.wheelKey] && opts.nav?.openWorkbench && (() => opts.nav.openWorkbench(WORKBENCH_TILE_KINDS[opts.wheelKey], item.id)), }), diff --git a/tests/fixtures/web_boundary_census.yaml b/tests/fixtures/web_boundary_census.yaml index 7128b840..bb254a6c 100644 --- a/tests/fixtures/web_boundary_census.yaml +++ b/tests/fixtures/web_boundary_census.yaml @@ -191,15 +191,15 @@ measured_at: "opensoft/openDox-code main a99eba03e31a0aee1cc15a061fdf718cc88a2c4 # shape and `test_the_declared_totals_are_re_derived_from_the_rows` can refuse a # drift between the two. Measured at slice S4 (see the S4 block above). totals: - A: {files: 26, loc: 18135} + A: {files: 26, loc: 18459} B: {files: 1, loc: 73} - C: {files: 14, loc: 12587} - "?": {files: 1, loc: 1577} + C: {files: 14, loc: 12757} + "?": {files: 1, loc: 1586} files: - path: app.js class: A - loc: 1719 - note: "the shell; its only class-B import was gate.js at :43 (discharged by S3's view registry -- loc 1155 -> 1282 with the registry wiring, S3; 1282 -> 1337 with S4's two new gate bindings and the two mounts it hands down). Carries SIX transitional route-ownership sites, all in the CORE arm's class-B binding entries and all cleared at S5 when those entries leave for contributedViewBindings(): gate.bar's one route (S3) and gate.lens's and gate.projects's two each (S4). Declaring them is RULED Q3 working -- view_extension.py: 'a binding that declares the routes it calls is what makes that ruling checkable instead of aspirational' -- and it is a real breach by assertion 2's own rule only while the entry sits in app.js (class A) rather than in the class-B module (exempt by construction) loc 1492 -> 1549 on this PR's round-2 review (Copilot): RULED Q1's generic pass is no longer ONE pass before every core mount — every `dom` region is a root its core renderer CLEARS, so the pass is run per region by the caller that just rendered it (after mountExplorer, after mountStagingWorkbench, and through the tab router's onRegionRendered hook on a tab's first render), and the Q11 refusal frame became a named function so a late refusal reaches the same surface. loc 1549 -> 1584 on round 3 (Copilot): the per-region pass is bound to THIS render's signal (a pass started from a tab's first render must not mount a stale snapshot into the next render's region), and the Q11 refusal frame re-inserts `#loadstatus` where a successful render removed it, so a late assembly refusal is visible instead of dropped. loc 1584 -> 1602 on round 4 (Copilot): the six contributed resolves each await a dynamic import and EVERYTHING after them mounts (the page-overlay host, the dispose panel into it, every region's core render), so a render whose scope was aborted mid-import now stops between the last resolve and the first mount rather than mounting its stale snapshot into the newer render; its error path stops with it, since a refusal from a render the user has switched away from would otherwise be written over the live render's own surface. loc 1602 -> 1618 on round 6 (Copilot): round 4's finding one and two awaits further down -- the generic mount pass is AWAITED after `mountExplorer` and after `mountStagingWorkbench`, and `mountContributedViews`'s own signal check (round 3) stops only ITS mounts, never the caller that resumes; below those two awaits sit the staging workbench, the tab strip, the repository selector and `status.remove()`, which would have stripped the NEWER render's loading state, so the resuming caller now stops at both." + loc: 1725 + note: "the shell; its only class-B import was gate.js at :43 (discharged by S3's view registry -- loc 1155 -> 1282 with the registry wiring, S3; 1282 -> 1337 with S4's two new gate bindings and the two mounts it hands down). Carries SIX transitional route-ownership sites, all in the CORE arm's class-B binding entries and all cleared at S5 when those entries leave for contributedViewBindings(): gate.bar's one route (S3) and gate.lens's and gate.projects's two each (S4). Declaring them is RULED Q3 working -- view_extension.py: 'a binding that declares the routes it calls is what makes that ruling checkable instead of aspirational' -- and it is a real breach by assertion 2's own rule only while the entry sits in app.js (class A) rather than in the class-B module (exempt by construction) loc 1492 -> 1549 on this PR's round-2 review (Copilot): RULED Q1's generic pass is no longer ONE pass before every core mount — every `dom` region is a root its core renderer CLEARS, so the pass is run per region by the caller that just rendered it (after mountExplorer, after mountStagingWorkbench, and through the tab router's onRegionRendered hook on a tab's first render), and the Q11 refusal frame became a named function so a late refusal reaches the same surface. loc 1549 -> 1584 on round 3 (Copilot): the per-region pass is bound to THIS render's signal (a pass started from a tab's first render must not mount a stale snapshot into the next render's region), and the Q11 refusal frame re-inserts `#loadstatus` where a successful render removed it, so a late assembly refusal is visible instead of dropped. loc 1584 -> 1602 on round 4 (Copilot): the six contributed resolves each await a dynamic import and EVERYTHING after them mounts (the page-overlay host, the dispose panel into it, every region's core render), so a render whose scope was aborted mid-import now stops between the last resolve and the first mount rather than mounting its stale snapshot into the newer render; its error path stops with it, since a refusal from a render the user has switched away from would otherwise be written over the live render's own surface. loc 1602 -> 1618 on round 6 (Copilot): round 4's finding one and two awaits further down -- the generic mount pass is AWAITED after `mountExplorer` and after `mountStagingWorkbench`, and `mountContributedViews`'s own signal check (round 3) stops only ITS mounts, never the caller that resumes; below those two awaits sit the staging workbench, the tab strip, the repository selector and `status.remove()`, which would have stripped the NEWER render's loading state, so the resuming caller now stops at both. loc 1719 -> 1725 on plan 034 T102 (RULED openxFactory#656 `5963618568`): `refusalTransport`, which is every standalone Save now, carries the model's GATELESS_SAVE_REFUSAL, naming the missing session transport, instead of a CLI remedy a standalone install does not have." - path: index.html class: C @@ -262,8 +262,8 @@ files: - path: views/doc-wheel.js class: C - loc: 502 - note: "docs-pane wheel; imports wheel-model.js" + loc: 517 + note: "docs-pane wheel; imports wheel-model.js loc 502 -> 517 on plan 034 T102 (RULED openxFactory#656 `5963618568`): the `edit` verb is offered per document where editing is by scope, and only a LOADED buffer's ownership outranks the tile's own." - path: views/docs.js class: A @@ -335,8 +335,8 @@ files: - path: views/lens.js class: "?" - loc: 1577 - note: "VOCABULARIES is a class-C vocabulary table; DTN_SEED_ROUTE and STAGING_SEED_ROUTE are answered ONLY by openxFactory's adapter (a THIRD column, note \u00a7 3.3). OUT OF SCOPE FOR S4 and still \"?\": none of Q1-Q5 rules on it (\u00a7 5's S4 row: 'lens.js is OUT OF SCOPE ... it stays \"?\" as assertion 1's one declared, ruled-later exception'). The S1 note read 'splits at S4', which contradicted that row; corrected by S4, which is the slice that would have had to do the splitting. Its two lane sites are assertion 2's own declared exception. loc 1524 -> 1435: the execute affordance, the organizer-evidence form and the outcome panel left for views/gate-lens.js with the two gate routes they called. loc 1435 -> 1495 when this file was WIRED ONTO THE DISPLAY FACET: app.js had passed it `display: ctx.display` since slice S7 and it was the last view in the bundle that never read it, so twenty-six rendered sites naming the SOURCE, GROUPING and SUBMISSION stations now resolve BY ROLE (sixteen in the first pass; the solo-term toggle, the matrix select-all, the drill rows, the staging-seed panel's four refusal captions and the two add-as- captions on Copilot's round-1 findings, and the matrix's own `doc` column heading on its round-2 finding — a word no sweep in this tree could have found, since only `docs` is watched) and three seam keys (`tab`, `scopeKind`) come off `views/display.js`'s own TAB_IDS / SCOPE_KINDS rather than being spelled a second time. THE CLASS DOES NOT MOVE and this act does not rule on it: reading the facet decided nothing about app.js's class either, and the two lane sites, the two routes and the VOCABULARIES table are untouched, so every sentence above still holds. Its module-level VOCABULARIES notes are the one place a station noun survives, and only two of them: they are evaluated at import and are now functions of the facet, called at the render that reads them. loc 1495 -> 1577 on plan 034 T088 (R1Q19 (a), openxFactory#656 comment 5850003126): the two seed actions are offered only where a binding answers their routes, read off the `/capabilities` `views.contributed_routes` the shell already fetched, so a standalone install offers neither, and the selection that exists only to feed the staging seed goes with it. THE CLASS DOES NOT MOVE: the two route literals, `DTN_SEED_ROUTE` and `STAGING_SEED_ROUTE`, and their `route_ownership_exceptions` entry are untouched, and the row stays \"?\" until R1Q19 (b), which moves the two controls into a view extension openxFactory contributes." + loc: 1586 + note: "VOCABULARIES is a class-C vocabulary table; DTN_SEED_ROUTE and STAGING_SEED_ROUTE are answered ONLY by openxFactory's adapter (a THIRD column, note \u00a7 3.3). OUT OF SCOPE FOR S4 and still \"?\": none of Q1-Q5 rules on it (\u00a7 5's S4 row: 'lens.js is OUT OF SCOPE ... it stays \"?\" as assertion 1's one declared, ruled-later exception'). The S1 note read 'splits at S4', which contradicted that row; corrected by S4, which is the slice that would have had to do the splitting. Its two lane sites are assertion 2's own declared exception. loc 1524 -> 1435: the execute affordance, the organizer-evidence form and the outcome panel left for views/gate-lens.js with the two gate routes they called. loc 1435 -> 1495 when this file was WIRED ONTO THE DISPLAY FACET: app.js had passed it `display: ctx.display` since slice S7 and it was the last view in the bundle that never read it, so twenty-six rendered sites naming the SOURCE, GROUPING and SUBMISSION stations now resolve BY ROLE (sixteen in the first pass; the solo-term toggle, the matrix select-all, the drill rows, the staging-seed panel's four refusal captions and the two add-as- captions on Copilot's round-1 findings, and the matrix's own `doc` column heading on its round-2 finding — a word no sweep in this tree could have found, since only `docs` is watched) and three seam keys (`tab`, `scopeKind`) come off `views/display.js`'s own TAB_IDS / SCOPE_KINDS rather than being spelled a second time. THE CLASS DOES NOT MOVE and this act does not rule on it: reading the facet decided nothing about app.js's class either, and the two lane sites, the two routes and the VOCABULARIES table are untouched, so every sentence above still holds. Its module-level VOCABULARIES notes are the one place a station noun survives, and only two of them: they are evaluated at import and are now functions of the facet, called at the render that reads them. loc 1495 -> 1577 on plan 034 T088 (R1Q19 (a), openxFactory#656 comment 5850003126): the two seed actions are offered only where a binding answers their routes, read off the `/capabilities` `views.contributed_routes` the shell already fetched, so a standalone install offers neither, and the selection that exists only to feed the staging seed goes with it. THE CLASS DOES NOT MOVE: the two route literals, `DTN_SEED_ROUTE` and `STAGING_SEED_ROUTE`, and their `route_ownership_exceptions` entry are untouched, and the row stays \"?\" until R1Q19 (b), which moves the two controls into a view extension openxFactory contributes. loc 1577 -> 1586 on plan 034 T102 (gap G8 of the T096 prep run): two notes stop naming a host's surfaces (`lens.py`, the workbench folder, the register, any queue), and the unused WORKBENCH_DIR import goes with the second. THE CLASS DOES NOT MOVE." - path: views/lineage.js class: C @@ -381,14 +381,14 @@ files: - path: views/staging-workbench-model.js class: A - loc: 2012 + loc: 2330 tail: capturedStatus - note: "SPLIT AT S4 (RULED Q3): all six class-B route constants left for the two class-B files that already called them and imported them -- CREATE_ROUTE :543 to views/swb-create.js, and EDIT_DOCUMENT_ROUTE :817 / OPEN_PR_ROUTE :818 / FIRST_EDIT_ROUTE :821 / ABANDON_SESSION_ROUTE :822 / SHARE_SESSION_ROUTE :826, with the SESSION_ROUTES table and sessionRoute(), to views/swb-session.js. What is left is the pure scope derivation, the affordance ids, the verbs, the labels and the request builders. The `tail:` is the note's own reading of the row (\u00a7 3.2: 'plus STATUS_BRAINSTORM / BRAINSTORM_AREA / STAGING_AREA (class-C)'): class-A substance with a class-C vocabulary tail. PARAMETERIZED AT S7: the three became capturedStatus() / capturedArea() / organizedArea(), read off the snapshot's closed documents[].stage enum and the profile's corpus areas, and the section labels became a function of the facet" + note: "SPLIT AT S4 (RULED Q3): all six class-B route constants left for the two class-B files that already called them and imported them -- CREATE_ROUTE :543 to views/swb-create.js, and EDIT_DOCUMENT_ROUTE :817 / OPEN_PR_ROUTE :818 / FIRST_EDIT_ROUTE :821 / ABANDON_SESSION_ROUTE :822 / SHARE_SESSION_ROUTE :826, with the SESSION_ROUTES table and sessionRoute(), to views/swb-session.js. What is left is the pure scope derivation, the affordance ids, the verbs, the labels and the request builders. The `tail:` is the note's own reading of the row (\u00a7 3.2: 'plus STATUS_BRAINSTORM / BRAINSTORM_AREA / STAGING_AREA (class-C)'): class-A substance with a class-C vocabulary tail. PARAMETERIZED AT S7: the three became capturedStatus() / capturedArea() / organizedArea(), read off the snapshot's closed documents[].stage enum and the profile's corpus areas, and the section labels became a function of the facet loc 2012 -> 2330 on plan 034 T102 (RULED openxFactory#656 `5963618568`, on `5961651355`): editing BY SCOPE -- `editingPosture`, `documentEditable`, `tileOwnEditablePaths` (the browser's mirror of `default_columns.resolve_scope`, held to it by tests/test_workbench_edit_by_scope.py), GATELESS_SAVE_REFUSAL and the two plane notes; `presentationPosture` gains the `editing` fact and is unchanged for every caller that does not pass the by-scope mode; `doxbenchScopeProjection` gains `editableBy: tile` (projecting no outline, as the neutral scope projects none) and `docWheelEntries` an optional editable set. Every new literal is plain English or a facet read." - path: views/staging-workbench.js class: C - loc: 3471 - note: "full-screen workbench scoped to one topic-bearing tile loc 3397 -> 3402 on this PR's round-2 review (Copilot): the two contributed columns are per-mount `const`s inside mountStagingWorkbench rather than module-level `let`s, so two live workbenches cannot share them. loc 3459 -> 3471 on the § 3.4 SLICE S7 RESIDUE (Copilot review 5192900474 on openDox-code#21 @ c7a216c, thread 2 and suppressed comment 5): the nested renderDocWheel is mounted WITH `display: vocab` (doc-wheel.js resets its module vocabulary to `opts.display || neutralDisplay()` on every render, so one pane had rendered in two vocabularies), and runAddSection() hands `display: vocab` to insertSection, so the only interactive add route places and duplicate-checks against the HOST's canonical heading order rather than the neutral SECTION_ORDER." + loc: 3621 + note: "full-screen workbench scoped to one topic-bearing tile loc 3397 -> 3402 on this PR's round-2 review (Copilot): the two contributed columns are per-mount `const`s inside mountStagingWorkbench rather than module-level `let`s, so two live workbenches cannot share them. loc 3459 -> 3471 on the § 3.4 SLICE S7 RESIDUE (Copilot review 5192900474 on openDox-code#21 @ c7a216c, thread 2 and suppressed comment 5): the nested renderDocWheel is mounted WITH `display: vocab` (doc-wheel.js resets its module vocabulary to `opts.display || neutralDisplay()` on every render, so one pane had rendered in two vocabularies), and runAddSection() hands `display: vocab` to insertSection, so the only interactive add route places and duplicate-checks against the HOST's canonical heading order rather than the neutral SECTION_ORDER. loc 3471 -> 3621 on plan 034 T102 (RULED openxFactory#656 `5963618568`): the canvas, the rail and the docs tile's `edit` verb are offered by `editingNow().editors` -- the gate where a host's column is registered (either half), exactly as before, and the scope where none is; the pill and the posture note state the by-scope posture, and the abstract's generation stays keyed on the gate (ruling 7.7). By scope there is no outline buffer, so the add-section controls state that absence, and a restored buffer the scope no longer owns is reconciled once the canvas is ready (Copilot review of #81): a clean one is unloaded, a dirty one is kept and named in the posture note." - path: views/swb-model-intake.js class: A @@ -412,8 +412,8 @@ files: - path: views/wheel.js class: C - loc: 1809 - note: "THE WHEEL; resolves intent-feed.js optionally (S2, RULED Q5/OQ-F); SOURCE_ROUTE :97 is clean since slice S6 -- RULED Q4 makes /source/ openDox's own core arm, so a class-C file naming it is no longer addressing another column's route. CLEAN OF CLASS B SINCE S5: its static import of dispose.js -- one of § 4.5 assertion 3's four breaches -- is gone; the shell resolves the gate.dispose binding and hands its declared namespace down as ctx.dispose, with a frozen null column when no gate column is registered. loc 1702 -> 1756 on S5, then 1756 -> 1769 on S5's round-2 review (Copilot): the contributed dispose column is a per-render `const` reaching ACTION_MOUNTERS through `opts.dispose` rather than a module-level `let` two live renders would share." + loc: 1814 + note: "THE WHEEL; resolves intent-feed.js optionally (S2, RULED Q5/OQ-F); SOURCE_ROUTE :97 is clean since slice S6 -- RULED Q4 makes /source/ openDox's own core arm, so a class-C file naming it is no longer addressing another column's route. CLEAN OF CLASS B SINCE S5: its static import of dispose.js -- one of § 4.5 assertion 3's four breaches -- is gone; the shell resolves the gate.dispose binding and hands its declared namespace down as ctx.dispose, with a frozen null column when no gate column is registered. loc 1702 -> 1756 on S5, then 1756 -> 1769 on S5's round-2 review (Copilot): the contributed dispose column is a per-render `const` reaching ACTION_MOUNTERS through `opts.dispose` rather than a module-level `let` two live renders would share. loc 1809 -> 1814 on plan 034 T102 (RULED openxFactory#656 `5963618568`): the workbench verb's title drops `(read-only)` and names the tile by the facet's word instead of its seam key." # ASSERTION 1's ONE DECLARED, RULED-LATER EXCEPTION (note § 4.5 point 1). After # slice S4 the census assertion fails on any row still "?" OR still "SPLIT" -- diff --git a/tests/test_doxbench_view.py b/tests/test_doxbench_view.py index a63187d8..714dcdf5 100644 --- a/tests/test_doxbench_view.py +++ b/tests/test_doxbench_view.py @@ -2884,12 +2884,20 @@ def test_staging_workbench_composes_the_doxbench_canvas_without_new_transport(): # answers false and the canvas is withheld with a stated posture instead of # the whole module failing to load. Every clause of the derivation is # untouched; what moved is the gate predicate's PROVENANCE. + # + # PIN EVOLUTION (plan 034 T102; RULED openxFactory#656 `5963618568`, "Edit + # and chat by scope"): the first two clauses are now read through ONE + # posture, `editingNow().editors`, because where no gate column is + # registered the editors are offered BY SCOPE rather than never. The gate + # clause and the hidden-plane clause are still both asked, and still of the + # same two predicates on the same `caps`, which is what the two `in view` + # lines below pin; `editingPosture` answers `editors` from them exactly as + # the old conjunction did wherever the gate is live, and its whole matrix + # is measured in `tests/test_workbench_edit_by_scope.py`. assert "function canvasOffered() {" in view - assert ( - "return !!scope && createColumn.createGateLive(caps) " - "&& !sessionSurfaceHidden(caps)" - in view - ) + assert "return !!scope && editingNow().editors" in view + assert "gateLive: createColumn.createGateLive(caps)," in view + assert "surfaceHidden: sessionSurfaceHidden(caps)," in view assert "&& !!active?.repository && !!active?.ref;" in view # …and the column the predicate is read off is the CONTRIBUTED one, with the # null column as the only fallback — never a re-imported module diff --git a/tests/test_workbench_edit_by_scope.py b/tests/test_workbench_edit_by_scope.py new file mode 100644 index 00000000..c01d10da --- /dev/null +++ b/tests/test_workbench_edit_by_scope.py @@ -0,0 +1,1034 @@ +"""The staging workbench offers the editors and the chat rail BY SCOPE (plan 034 +T102). + +RULED by Brett Heap, opensoft/openxFactory#656 comment `5963618568`, "Edit and +chat by scope (Recommended)": the editors and the chat rail appear wherever the +scope lets the document be edited, and only creating documents and Save stay +behind the gate, so Save is refused by name. It builds on `5961651355`, "Tile's +own documents editable (Recommended)": openDox's own neutral scope default +(`opendox.default_columns.resolve_scope`, T084) marks a tile's OWN documents +editable. + +The defect this measures. A standalone install registers no +`gate.workbench.create` column, so `createColumn.createGateLive(caps)` was the +null column's `false`, and every workbench it opened was read-only: no editor, +no rail, and a disabled `edit` verb, though its `/capabilities` reads +`edit: true` and its own scope makes the tile's documents editable (the T096 +prep run, AT-R1 step 7). + +Three layers, each against the code the browser runs: + +1. THE POSTURE MATRIX, pure (`editingPosture`, `documentEditable`, + `presentationPosture`): gate on/off x edit on/off x document editable/not, + with the governed column as the fourth fact. +2. PARITY with the server: the browser's `tileOwnEditablePaths` against + `default_columns.resolve_scope(...).editable_paths`, tile by tile. +3. THE REAL SHELL, mounted in node over the shared DOM instrument, standalone + (the null columns, exactly as `app.js` hands them down) and governed (the + contributed column the other shell harnesses mount). + +Two mutants this module kills, recorded in the PR: the old gate-only posture +(the standalone cases fail: no canvas), and an editable-everything posture (the +nothing-editable, per-document and parity cases fail). +""" + +from __future__ import annotations + +import json +import shutil +import subprocess + +import pytest + +from conftest import REPO_ROOT +from opendox import default_columns as dc +from opendox import projection_seams as ps +from opendox.doxbench_scope_types import ScopeConfinementError, ScopeKey +# The SAME DOM instrument and the SAME contributed column every other shell +# harness mounts with, imported rather than copied so they cannot drift. +from test_doxbench_view import _CONTRIBUTED_GATE, _EDITOR_DOM_SHIM + +NODE = shutil.which("node") +WEB = REPO_ROOT / "src" / "opendox" / "web" +VIEWS = WEB / "views" +VENDOR = WEB / "vendor" + +# ONE snapshot for every layer. Each tile is chosen for a reason: +# g1 a group whose two members are both catalogued and on disk +# g2 a group with an uncatalogued member (`gone.md`) and one (`both.md`) +# that a candidate also CITES +# g3 a group with no members at all +# gbad a group carrying a path the scope cannot name safely +# p1 a candidate citing `cited.md` and `both.md`, claimed by g1 and g2 +# p2 a candidate that only cites: nothing of its own to edit +# p3 a candidate claimed by a group that does not exist +# s1 a selection naming one catalogued file and one that is not +# g4 a group naming openDox's own settings documents (T084's M1): the +# catalogued one stays readable and is never editable +# g5 a group whose edge names a document by an ID that is not its path +# (T084 fix round 3), claimed by candidate p4; selection s2 names the +# same document by its PATH +# g6, s3 the two namespaces kept apart (T084 fix round 4): `real-p.md` has +# the ID `p.md`, and another document has the PATH `p.md`. g6's edge +# `p.md` is the id, so `real-p.md`; s3's file `p.md` is the path, and +# its file `notes/soil-test` is an id, not a path, so it resolves to +# nothing +BINDINGS = "ideation/dashboard/model-provider-bindings.yaml" +DECLARATIONS = "ideation/dashboard/model-declarations.yaml" +SNAPSHOT = { + "repository": "fixture", + "generation": {"source_revision": "1" * 40}, + "documents": [ + {"id": p, "path": p, "topics": ["barrel"]} + for p in ("a.md", "b.md", "c.md", "sel.md", "cited.md", "both.md") + ] + [{"id": "decl.md", "path": "decl.md", "topics": ["barrel"], + "destinations": {"staged_topics": ["s1"]}}, + {"id": BINDINGS, "path": BINDINGS, "topics": []}, + {"id": "notes/soil-test", "path": "notes/soil-test.md", "topics": []}, + {"id": "p.md", "path": "real-p.md", "topics": []}, + {"id": "other-p", "path": "p.md", "topics": []}], + "clusters": [ + {"id": "g1", "name": "Group one", "topics": ["barrel"], + "document_edges": [{"document": "a.md"}, {"document": "b.md"}]}, + {"id": "g2", "name": "Group two", "topics": ["barrel"], + "document_edges": [{"document": "c.md"}, {"document": "gone.md"}, + {"document": "both.md"}]}, + {"id": "g3", "name": "Group three", "topics": [], "document_edges": []}, + {"id": "gbad", "name": "Group bad", "topics": [], + "document_edges": [{"document": "a.md"}, {"document": "../escape.md"}]}, + {"id": "g4", "name": "Group four", "topics": [], + "document_edges": [{"document": BINDINGS}, {"document": "a.md"}, + {"document": DECLARATIONS}]}, + {"id": "g5", "name": "Group five", "topics": [], + "document_edges": [{"document": "notes/soil-test"}, {"document": "b.md"}]}, + {"id": "g6", "name": "Group six", "topics": [], + "document_edges": [{"document": "p.md"}]}, + ], + "possibles": [ + {"id": "p1", "title": "Candidate one", + "supporting_evidence": [{"document": "cited.md"}, {"document": "both.md"}], + "claiming_clusters": ["g1", "g2"]}, + {"id": "p2", "title": "Candidate two", + "supporting_evidence": [{"document": "cited.md"}], + "claiming_clusters": []}, + {"id": "p3", "title": "Candidate three", + "supporting_evidence": [], "claiming_clusters": ["nope"]}, + {"id": "p4", "title": "Candidate four", + "supporting_evidence": [], "claiming_clusters": ["g5"]}, + ], + "staged_topics": [{"staging_id": "s1", "files": ["sel.md", "notes.yaml"]}, + {"staging_id": "s2", "files": ["notes/soil-test.md"]}, + {"staging_id": "s3", "files": ["p.md", "notes/soil-test"]}], +} + +# Every file the scope resolves inside the checkout. `gone.md` is listed by g2 +# and catalogued by nothing, so it is neither here nor in `documents`. +ON_DISK = ("a.md", "b.md", "c.md", "sel.md", "cited.md", "both.md", "decl.md", + BINDINGS, DECLARATIONS, "notes/soil-test.md", "real-p.md", "p.md") + +PARITY_TILES = [ + ("cluster", "g1"), ("cluster", "g2"), ("cluster", "g3"), + ("cluster", "gbad"), ("cluster", "g4"), ("cluster", "g5"), ("cluster", "g6"), + ("cluster", "nope"), + ("possible", "p1"), ("possible", "p2"), ("possible", "p3"), + ("possible", "p4"), ("possible", "nope"), + ("staged", "s1"), ("staged", "s2"), ("staged", "s3"), ("staged", "nope"), +] + + +def _stage(tmp_path_factory, name: str, harness: str) -> dict: + """Copy the bundle's views and vendor next to `harness`, run it under node + over the shared snapshot, and return the one JSON object it prints.""" + if NODE is None: + pytest.skip("node not available for the workbench edit-by-scope probe") + root = tmp_path_factory.mktemp(name) + shutil.copytree(VIEWS, root / "views") + shutil.copytree(VENDOR, root / "vendor") + (root / "package.json").write_text('{"type": "module"}', encoding="utf-8") + (root / "vendor" / "package.json").write_text('{"type": "commonjs"}', + encoding="utf-8") + snapshot = root / "snapshot.json" + snapshot.write_text(json.dumps(SNAPSHOT), encoding="utf-8") + script = root / "views" / (name + ".mjs") + script.write_text(harness, encoding="utf-8") + done = subprocess.run([NODE, str(script), str(snapshot)], + capture_output=True, text=True, timeout=180) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +# =========================================================================== +# 1. THE POSTURE MATRIX, pure +# =========================================================================== + +_MODEL_HARNESS = r""" +import { readFileSync } from 'node:fs'; +const m = await import('./staging-workbench-model.js'); +// the shell's OWN outline derivation (staging-workbench.js canvasProjection), +// so a tile-mode projection that kept an outline would show it here +const { primaryFragmentPath } = await import('./wheel-model.js'); +const realOutlinePathFor = (outline) => + primaryFragmentPath(outline.stagingId, outline.files); +const SNAP = JSON.parse(readFileSync(process.argv[2], 'utf-8')); +const out = {}; + +out.modes = m.EDITING_MODES; +out.matrix = []; +for (const governed of [true, false]) { + for (const gate of [true, false]) { + // a gate is a HOST's column: openDox's null column always answers false + if (gate && !governed) continue; + for (const edit of [true, false]) { + for (const editable of [true, false]) { + const p = m.editingPosture({ + governed, gateLive: gate, editLive: edit, surfaceHidden: false, + editablePaths: editable ? ['a.md'] : [] }); + out.matrix.push({ governed, gate, edit, editable, + mode: p.mode, editors: p.editors, create: p.create, save: p.save, + editablePaths: p.editablePaths, frozen: Object.isFrozen(p), + docEditable: m.documentEditable(p, 'a.md'), + otherEditable: m.documentEditable(p, 'z.md') }); + } + } + } +} +out.hidden = [true, false].map((governed) => m.editingPosture({ + governed, gateLive: governed, editLive: true, surfaceHidden: true, + editablePaths: ['a.md'] })); +// `edit` must be TRUE, not merely present: an older serve reports none +out.editAbsent = m.editingPosture({ governed: false, gateLive: false, + editablePaths: ['a.md'] }).mode; + +const plane = (extra) => ({ repository: 'fixture', ref: 'main', + sourceAvailable: true, approvedModelCount: 0, ...extra }); +out.posture = { + // a governed host, which never passes the by-scope mode: as before T102 + governedGateOffBare: m.presentationPosture(plane({ gateLive: false })), + governedGateOff: m.presentationPosture(plane({ gateLive: false, + editing: m.EDITING_MODES.readOnly })), + governedGateOnBare: m.presentationPosture(plane({ gateLive: true })), + governedGateOn: m.presentationPosture(plane({ gateLive: true, + editing: m.EDITING_MODES.gate })), + // standalone + scopeNoModel: m.presentationPosture(plane({ gateLive: false, + editing: m.EDITING_MODES.scope })), + scopeWithModel: m.presentationPosture(plane({ gateLive: false, + editing: m.EDITING_MODES.scope, approvedModelCount: 1 })), + scopeUnkeyed: m.presentationPosture(plane({ gateLive: false, + editing: m.EDITING_MODES.scope, ref: '' })), + nothingEditable: m.presentationPosture(plane({ gateLive: false, + editing: m.EDITING_MODES.nothing })), + hiddenScope: m.presentationPosture(plane({ gateLive: false, + editing: m.EDITING_MODES.scope, surfaceHidden: true })), +}; +out.notes = { scope: m.scopeEditingNote(), nothing: m.nothingEditableNote(), + gateless: m.GATELESS_SAVE_REFUSAL }; + +// the projection the canvas mounts over, both authorities +const outlinePathFor = () => null; +out.projection = { + tile: m.doxbenchScopeProjection(SNAP, 'possible', 'p1', + { repository: 'fixture', ref: 'main', outlinePathFor, editableBy: 'tile' }), + host: m.doxbenchScopeProjection(SNAP, 'possible', 'p1', + { repository: 'fixture', ref: 'main', outlinePathFor }), +}; +const scope = m.workbenchScope(SNAP, 'possible', 'p1'); +out.entries = { + byScope: m.docWheelEntries(scope, { editable: m.tileOwnEditablePaths(SNAP, 'possible', 'p1') }) + .map((e) => ({ path: e.path, editable: e.editable, owned: e.owned })), + byGate: m.docWheelEntries(scope) + .map((e) => ({ path: e.path, keys: Object.keys(e).sort() })), +}; +out.parity = {}; +out.parityProjection = {}; +for (const [kind, id] of JSON.parse(process.argv[3] || '[]')) { + out.parity[kind + '/' + id] = m.tileOwnEditablePaths(SNAP, kind, id); + const p = m.doxbenchScopeProjection(SNAP, kind, id, { repository: 'fixture', + ref: 'main', outlinePathFor: realOutlinePathFor, editableBy: 'tile' }); + out.parityProjection[kind + '/' + id] = p && { + context: p.context_paths, editable: p.editable_paths, + candidates: p.active_document_candidates, outline: p.outline_path }; +} +// the SAME tile under a governed host keeps its outline, which is what makes +// the tile-mode null above a decision rather than an accident of the fixture +const hostS1 = m.doxbenchScopeProjection(SNAP, 'staged', 's1', { + repository: 'fixture', ref: 'main', outlinePathFor: realOutlinePathFor }); +out.hostOutline = { s1: hostS1 && hostS1.outline_path }; +out.settingsDocuments = m.OWN_SETTINGS_DOCUMENTS; +console.log(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def model(tmp_path_factory): + if NODE is None: + pytest.skip("node not available for the workbench edit-by-scope probe") + root = tmp_path_factory.mktemp("edit-by-scope-model") + shutil.copytree(VIEWS, root / "views") + (root / "package.json").write_text('{"type": "module"}', encoding="utf-8") + snapshot = root / "snapshot.json" + snapshot.write_text(json.dumps(SNAPSHOT), encoding="utf-8") + script = root / "views" / "model-harness.mjs" + script.write_text(_MODEL_HARNESS, encoding="utf-8") + done = subprocess.run( + [NODE, str(script), str(snapshot), json.dumps(PARITY_TILES)], + capture_output=True, text=True, timeout=60) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +# (governed, gate, edit, editable) -> (mode, editors, create, save, +# this document editable, a document outside the scope editable) +MATRIX = { + # A GOVERNED HOST: its gate decides, exactly as before T102. + (True, True, True, True): ("gate", True, True, "gate", True, True), + (True, True, True, False): ("gate", True, True, "gate", True, True), + (True, True, False, True): ("gate", True, True, "gate", True, True), + (True, True, False, False): ("gate", True, True, "gate", True, True), + (True, False, True, True): ("read-only", False, False, "absent", False, False), + (True, False, True, False): ("read-only", False, False, "absent", False, False), + (True, False, False, True): ("read-only", False, False, "absent", False, False), + (True, False, False, False): ("read-only", False, False, "absent", False, False), + # STANDALONE: no column, so no gate; editing by scope. + (False, False, True, True): ("scope", True, False, "refused", True, False), + (False, False, True, False): ("nothing-editable", False, False, "absent", False, False), + (False, False, False, True): ("read-only", False, False, "absent", False, False), + (False, False, False, False): ("read-only", False, False, "absent", False, False), +} + + +def test_the_posture_matrix_gate_by_edit_by_editable(model) -> None: + """Every cell, against the table above. Under the gate the editors are + offered and every document loads, as before; by scope the editors are + offered only where the scope makes a document editable, create is absent, + Save is present and refused, and only the scope's documents load.""" + seen = {} + for row in model["matrix"]: + key = (row["governed"], row["gate"], row["edit"], row["editable"]) + seen[key] = (row["mode"], row["editors"], row["create"], row["save"], + row["docEditable"], row["otherEditable"]) + assert row["frozen"] is True, key + assert seen == MATRIX + + +def test_the_by_scope_posture_carries_exactly_the_scopes_set(model) -> None: + by_scope = [r for r in model["matrix"] if r["mode"] == "scope"] + assert [r["editablePaths"] for r in by_scope] == [["a.md"]] + # under the gate there is no set: every document stays loadable + assert all(r["editablePaths"] is None for r in model["matrix"] + if r["mode"] == "gate") + + +def test_the_hosted_plane_offers_no_editing_whatever_else_it_claims(model) -> None: + for posture in model["hidden"]: + assert posture["mode"] == "hidden" + assert posture["editors"] is False and posture["create"] is False + + +def test_edit_must_be_stated_true_not_merely_absent(model) -> None: + """An older serve reports no `edit` key; that is not a licence to edit.""" + assert model["editAbsent"] == "read-only" + + +GATE_OFF = { + "kind": "gate-off", "canvas": False, + "note": ("read-only: the create/edit gate is off here, so editing, chat, " + "and Save are not offered — retained context stays readable."), +} +EDITOR_ONLY = { + "kind": "editor-only", "canvas": True, "chat": True, "intakeOffered": False, + "note": ("chat is unavailable — no approved model is configured; both " + "editors remain fully usable."), +} + + +def test_a_governed_hosts_presentation_posture_is_unchanged(model) -> None: + """The ladder reads exactly what it read before T102 for every caller that + does not pass the by-scope mode, with or without the new fact.""" + posture = model["posture"] + assert posture["governedGateOffBare"] == GATE_OFF + assert posture["governedGateOff"] == GATE_OFF + assert posture["governedGateOnBare"] == EDITOR_ONLY + assert posture["governedGateOn"] == EDITOR_ONLY + + +def test_the_by_scope_posture_offers_the_canvas_and_states_save_by_name(model) -> None: + posture = model["posture"] + scoped = posture["scopeNoModel"] + # the chat rung is unchanged (its sentence is the send button's)… + assert {k: scoped[k] for k in EDITOR_ONLY} == EDITOR_ONLY + # …and the plane fact rides beside it + assert scoped["editing"] == "scope" + assert scoped["scopeNote"] == model["notes"]["scope"] + assert "Save is refused by name" in scoped["scopeNote"] + assert "source items" in scoped["scopeNote"], "the facet's word, not a literal" + capable = posture["scopeWithModel"] + assert capable["kind"] == "capable-local" and capable["canvas"] is True + assert capable["scopeNote"] == model["notes"]["scope"] + # a posture that withholds the canvas states no editing it does not offer + assert posture["scopeUnkeyed"]["kind"] == "unkeyed" + assert "scopeNote" not in posture["scopeUnkeyed"] + assert posture["hiddenScope"]["kind"] == "hosted-hidden" + assert "scopeNote" not in posture["hiddenScope"] + + +def test_nothing_editable_in_scope_says_so_rather_than_blaming_a_gate(model) -> None: + nothing = model["posture"]["nothingEditable"] + assert nothing == {"kind": "nothing-editable", "canvas": False, + "note": model["notes"]["nothing"]} + assert "gate" not in nothing["note"] + + +def test_the_tile_projection_mirrors_the_neutral_scope(model) -> None: + """`editableBy: "tile"` is what a standalone server answers: the tile's own + documents, editable, and nothing else in context. The host projection is + unchanged, and on this candidate owns nothing.""" + tile = model["projection"]["tile"] + assert tile["editable_paths"] == ["a.md", "b.md", "c.md", "both.md"] + assert tile["context_paths"] == ["a.md", "b.md", "c.md", "both.md"] + assert "cited.md" not in tile["context_paths"] + assert tile["active_document_candidates"] == ["a.md", "b.md", "c.md", "both.md"] + assert tile["outline_path"] is None + host = model["projection"]["host"] + assert host["editable_paths"] == [] + assert host["context_paths"] == ["cited.md", "both.md", "a.md", "b.md", "c.md"] + assert host["active_document_candidates"] == [] + + +def test_each_docs_tile_says_whether_it_is_the_tiles_own(model) -> None: + by_scope = {e["path"]: (e["editable"], e["owned"]) + for e in model["entries"]["byScope"]} + assert by_scope == { + "cited.md": (False, False), + "both.md": (True, True), # cited AND claimed: the scope's answer wins + "a.md": (True, True), "b.md": (True, True), "c.md": (True, True), + "gone.md": (False, False), + } + # under the gate the entries carry neither key, as before + assert all("editable" not in e["keys"] and "owned" not in e["keys"] + for e in model["entries"]["byGate"]) + + +# =========================================================================== +# 2. PARITY with the server's neutral scope +# =========================================================================== + +@pytest.fixture() +def corpus(tmp_path): + for name in ON_DISK: + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text("# x\n", encoding="utf-8") + ps.register_defaults() # the registry seam's containment rule + return tmp_path + + +@pytest.mark.parametrize("kind,tile", PARITY_TILES, + ids=[f"{k}-{t}" for k, t in PARITY_TILES]) +def test_the_browser_set_is_the_servers_set(model, corpus, kind, tile) -> None: + """`tileOwnEditablePaths` against `default_columns.resolve_scope`, tile by + tile: the same paths in the same order. Where the server cannot project a + tile at all (an unknown one answers None, a path it cannot name raises), + the browser offers nothing.""" + key = ScopeKey(repository="fixture", ref="main", tile_kind=kind, tile_id=tile) + try: + projection = dc.resolve_scope(SNAPSHOT, key, source_root=corpus) + except ScopeConfinementError: + projection, refused = None, True + else: + refused = False + server = () if projection is None else projection.editable_paths + assert tuple(model["parity"][f"{kind}/{tile}"]) == tuple(server) + # …and the projection the canvas mounts over, list for list: what is in + # context (the settings documents trail it, readable), what is editable, + # and what the turn guard would accept + browser = model["parityProjection"][f"{kind}/{tile}"] + if projection is None: + assert browser is None or (refused and browser == { + "context": [], "editable": [], "candidates": [], + "outline": None}), browser + else: + # the neutral scope projects NO outline (Copilot review of #81, + # r4173502649): the turn guard's `_require_buffer_binding` requires the + # outline buffer's path to equal it, so a browser that sent one would + # have every turn on a staged or candidate tile refused + assert projection.outline_path is None + assert browser == { + "context": list(projection.context_paths), + "editable": list(projection.editable_paths), + "candidates": list(projection.active_document_candidates), + "outline": projection.outline_path} + + +def test_the_settings_documents_are_spelled_as_the_server_spells_them(model) -> None: + assert set(model["settingsDocuments"]) == set(dc.SETTINGS_DOCUMENTS) + assert len(model["settingsDocuments"]) == len(dc.SETTINGS_DOCUMENTS) + + +def test_the_parity_fixture_reaches_every_branch(model, corpus) -> None: + """A parity table of empty answers would agree vacuously, so the fixture's + interesting tiles are held to their shapes too.""" + parity = model["parity"] + assert parity["cluster/g1"] == ["a.md", "b.md"] + assert parity["cluster/g2"] == ["c.md", "both.md"], "gone.md is uncatalogued" + assert parity["possible/p1"] == ["a.md", "b.md", "c.md", "both.md"] + assert parity["staged/s1"] == ["sel.md"] + assert parity["cluster/gbad"] == [], "one unnameable path refuses the tile" + # T084's M1: the catalogued settings document is readable, never editable + assert parity["cluster/g4"] == ["a.md"] + assert model["parityProjection"]["cluster/g4"]["context"] == ["a.md", BINDINGS] + # T084 fix round 3: an edge names its document by id, a file by path + assert parity["cluster/g5"] == ["notes/soil-test.md", "b.md"] + assert parity["possible/p4"] == ["notes/soil-test.md", "b.md"] + assert parity["staged/s2"] == ["notes/soil-test.md"] + # T084 fix round 4: an edge is looked up as an id, a file as a path, apart + assert parity["cluster/g6"] == ["real-p.md"] + assert parity["staged/s3"] == ["p.md"], "a file that is only an id resolves to nothing" + # a governed host's projection of s1 keeps its outline (the shell's own + # derivation finds one), so the by-scope null is reached, not vacuous + assert model["hostOutline"]["s1"] == "sel.md" + assert model["parityProjection"]["staged/s1"]["outline"] is None + assert "sel.md" in model["parityProjection"]["staged/s1"]["candidates"] + with pytest.raises(ScopeConfinementError): + dc.resolve_scope(SNAPSHOT, ScopeKey(repository="fixture", ref="main", + tile_kind="cluster", tile_id="gbad"), + source_root=corpus) + + +# =========================================================================== +# 3. THE REAL SHELL, standalone and governed +# =========================================================================== + +_SHELL_HARNESS = _EDITOR_DOM_SHIM + _CONTRIBUTED_GATE + r""" +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; + +globalThis.markdownit = createRequire(import.meta.url)('../vendor/markdown-it.min.js'); +globalThis.window = { location: { href: 'http://localhost/' } }; + +const { mountStagingWorkbench } = await import('./staging-workbench.js'); +const { runSave, savePlanState } = await import('./doxbench-save.js'); +const model = await import('./staging-workbench-model.js'); +const SNAP = JSON.parse(readFileSync(process.argv[2], 'utf-8')); + +const settle = () => new Promise((r) => setTimeout(r, 0)); +async function quiesce(n = 30) { for (let i = 0; i < n; i += 1) await settle(); } +async function until(predicate, label) { + for (let i = 0; i < 600; i += 1) { + if (predicate()) return; + await settle(); + } + throw new Error('timed out waiting for ' + label); +} +function fire(node, type, ev = {}) { + const listeners = (node.listeners && node.listeners[type]) || []; + return Promise.all(listeners.map( + (fn) => fn({ target: node, stopPropagation() {}, preventDefault() {}, ...ev }))); +} +class FakeStorage { + constructor() { this.values = new Map(); } + getItem(k) { return this.values.has(k) ? this.values.get(k) : null; } + setItem(k, v) { this.values.set(k, String(v)); } + removeItem(k) { this.values.delete(k); } +} + +// THE STANDALONE SAVE, composed exactly as `app.js` composes it where no host +// contributes `gate.workbench.session`: `runSave` over `refusalTransport()`, +// whose message is the model's one sentence (pinned at app.js below). +const gatelessSave = (request) => runSave(savePlanState(request), { + transport: async () => ({ ok: false, error: 'no_gate_column', + message: model.GATELESS_SAVE_REFUSAL }), + ...(request && request.only !== undefined ? { only: request.only } : {}) }); + +// What a standalone `/capabilities` reads (the T096 prep run's caps.json): +// gate false, edit true, session true. +const STANDALONE = { actions: { notebook: false, gate: false, refresh: false, + session: true, edit: true, intent: false }, actor: 'fixture', + console_token: 'token' }; +const GOVERNED = { actions: { gate: true, session: true, edit: true }, + actor: 'brett' }; + +async function mount({ caps, gate, kind, id, storage, snapshot }) { + const container = document.createElement('div'); + const log = { turns: [], abstracts: [] }; + const doxbench = { + loadSource: async (path) => ({ content: '# ' + path + '\n\nbody\n', ref: 'main' }), + storage: storage || new FakeStorage(), + // no model is configured anywhere in this module + catalog: async () => ({ schema_version: 1, kind: 'workbench-model-catalog', + models: [] }), + chatTurn: async (request) => { log.turns.push(request); + return { ok: false, status: 403, + payload: { error: 'model_capability_unavailable' } }; }, + save: gatelessSave, + documentAbstract: async (request) => { log.abstracts.push(request); + return { ok: false, payload: { error: 'model_capability_unavailable' } }; }, + }; + const workbench = mountStagingWorkbench(container, snapshot || SNAP, { + caps, gate, onOpenDoc: () => null, fetcher: async () => ({ ok: false }), + active: { repository: 'fixture', ref: 'main' }, index: { entries: [] }, + doxbench, sourceBase: '/source/', edit: null, + onSessionRekey: async () => null, onSessionEnded: async () => null, + onScopeOpened: () => null, + }); + workbench.open(kind, id); + const byClass = (cls) => container.walk().filter( + (n) => String(n.className).split(' ').includes(cls)); + const one = (cls) => byClass(cls)[0] || null; + await quiesce(80); + return { container, workbench, log, byClass, one }; +} + +function survey(ctx) { + const head = ctx.one('swb-head'); + const pill = head ? head.children.find( + (n) => String(n.className).split(' ').includes('pill')) : null; + const canvas = ctx.one('doxbench-canvas'); + const rail = ctx.one('doxbench-rail'); + const note = ctx.one('swb-posture-note'); + const actions = ctx.byClass('swb-actions'); + return { + pill: pill ? pill.textContent : null, + pillTitle: pill ? pill.title : null, + canvasHidden: canvas ? canvas.hidden : null, + textareas: ctx.byClass('doxbench-textarea').length, + railHidden: rail ? rail.hidden : null, + composer: ctx.byClass('doxchat-composer').length, + send: ctx.byClass('doxchat-send').length, + sendDisabled: (ctx.one('doxchat-send') || {}).disabled === true, + note: note && !note.hidden ? note.textContent : '', + createButtons: actions.reduce((n, a) => + n + a.walk().filter((x) => x.tagName === 'BUTTON').length, 0), + }; +} + +// The docs wheel needs a measurable host before it lays tiles out, and only a +// laid-out expanded tile mounts its action row (the composition harness's way). +async function expand(ctx, path) { + const pane = ctx.container.walk().find((n) => n.__docWheel); + ctx.one('swb-docselector').clientHeight = 420; + pane.__docWheelRefresh(); + await quiesce(5); + const tiles = ctx.byClass('wheeltile'); + const index = tiles.findIndex((t) => t.title === path); + if (index < 0) throw new Error('no tile for ' + path); + for (let attempt = 0; attempt < 3; attempt += 1) { + if (tiles[index].querySelector('.wheelactions')) break; + await fire(tiles[index], 'click'); + await quiesce(5); + } + const row = tiles[index].querySelector('.wheelactions'); + if (!row) throw new Error('tile for ' + path + ' never expanded'); + const verb = (cls) => { + const b = row.querySelector('.' + cls); + return b ? { disabled: b.disabled === true, title: b.title, node: b } : null; + }; + return { load: verb('swb-docload'), save: verb('swb-docsave') }; +} + +// Whether the docs pane offers the model-derived abstract's GENERATE control: +// switch the region to the model view, then look (ruling 7.7 keys it on the gate). +async function generateOffered(ctx) { + const toggle = ctx.one('swb-abstracttoggle'); + if (!toggle) return null; + await fire(toggle, 'click'); + await quiesce(5); + return ctx.byClass('swb-abstractgenerate').length; +} + +const plain = (verb) => (verb ? { disabled: verb.disabled, title: verb.title } : null); +const out = {}; +// A scenario that cannot complete RECORDS why and the next one still runs, so +// a regression reads as a failed assertion naming the step, not as one fixture +// error swallowing every case below it. +async function scenario(name, body) { + const o = (out[name] = {}); + try { await body(o); } catch (e) { o.error = String((e && e.message) || e); } +} + +// ---- S1: STANDALONE, a group whose members are its own --------------------- +await scenario('s1', async (o) => { + // the null columns, exactly as app.js hands them down with no binding + const ctx = await mount({ caps: STANDALONE, gate: { create: null, session: null }, + kind: 'cluster', id: 'g1' }); + await until(() => ctx.byClass('doxbench-textarea').length >= 2, 'the canvas'); + await until(() => ctx.byClass('doxchat-composer').length > 0, 'the rail'); + await quiesce(40); + Object.assign(o, survey(ctx)); + o.generate = await generateOffered(ctx); + const b = await expand(ctx, 'b.md'); + o.loadB = plain(b.load); + await fire(b.load.node, 'click'); + await quiesce(60); + o.verbNote = String((ctx.one('swb-docverbnote') || {}).textContent || ''); + o.textareasAfterLoad = ctx.byClass('doxbench-textarea').length; + // type into the document the load selected, and Save from the canvas + const areas = ctx.byClass('doxbench-textarea'); + const typed = areas[areas.length - 1]; + typed.value = typed.value + 'typed by a human\n'; + await fire(typed, 'input'); + await until(() => ctx.byClass('doxbench-save')[0].disabled !== true, 'a live Save'); + await fire(ctx.byClass('doxbench-save')[0], 'click'); + await quiesce(80); + o.canvasSaveRefusedByName = ctx.container.textContent.includes( + 'Save refused -- ' + model.GATELESS_SAVE_REFUSAL); + o.typedSurvives = typed.value.endsWith('typed by a human\n'); + // …and from the docs tile's own Save, the second entry point + const again = await expand(ctx, 'b.md'); + o.tileSave = plain(again.save); + if (again.save && !again.save.disabled) { + await fire(again.save.node, 'click'); + await quiesce(80); + } + o.tileSaveNote = String((ctx.one('swb-docverbnote') || {}).textContent || ''); + o.turns = ctx.log.turns.length; + o.abstracts = ctx.log.abstracts.length; +}); + +// ---- S2: STANDALONE, a candidate: its claiming groups' members, not its citations +await scenario('s2', async (o) => { + const ctx = await mount({ caps: STANDALONE, gate: { create: null, session: null }, + kind: 'possible', id: 'p1' }); + await until(() => ctx.byClass('doxbench-textarea').length >= 2, 'the canvas'); + await quiesce(40); + Object.assign(o, survey(ctx)); + o.verbs = {}; + for (const path of ['cited.md', 'both.md', 'a.md']) { + const v = await expand(ctx, path); + o.verbs[path] = { load: plain(v.load), save: plain(v.save) }; + } +}); + +// ---- S3: STANDALONE, a candidate with nothing of its own --------------------- +await scenario('s3', async (o) => { + const ctx = await mount({ caps: STANDALONE, gate: { create: null, session: null }, + kind: 'possible', id: 'p2' }); + Object.assign(o, survey(ctx)); + const v = await expand(ctx, 'cited.md'); + o.load = plain(v.load); +}); + +// ---- S4: STANDALONE without the edit capability (no identity, say) ----------- +await scenario('s4', async (o) => { + const caps = { ...STANDALONE, actions: { ...STANDALONE.actions, edit: false } }; + const ctx = await mount({ caps, gate: { create: null, session: null }, + kind: 'cluster', id: 'g1' }); + Object.assign(o, survey(ctx)); +}); + +// ---- S5: GOVERNED, the gate live: exactly as before T102 --------------------- +await scenario('s5', async (o) => { + const ctx = await mount({ caps: GOVERNED, gate: contributedGate().column, + kind: 'possible', id: 'p1' }); + await until(() => ctx.byClass('doxbench-textarea').length >= 2, 'the canvas'); + await quiesce(40); + Object.assign(o, survey(ctx)); + o.generate = await generateOffered(ctx); + const v = await expand(ctx, 'cited.md'); + o.loadCited = plain(v.load); +}); + +// ---- S6: GOVERNED, the gate off and `edit` true: read-only, as before -------- +await scenario('s6', async (o) => { + const caps = { actions: { gate: false, session: true, edit: true }, actor: 'brett' }; + const ctx = await mount({ caps, gate: contributedGate().column, + kind: 'cluster', id: 'g1' }); + Object.assign(o, survey(ctx)); +}); + +// ---- S7: STANDALONE, a RESTORE the scope no longer owns (Copilot review of +// #81, r4173470792). The first mount loads both of g1's members and types into +// b.md; the snapshot is then regenerated with g1 holding c.md alone, and the +// same browser storage restores the session into it. +await scenario('s7', async (o) => { + const storage = new FakeStorage(); + const first = await mount({ caps: STANDALONE, gate: { create: null, session: null }, + kind: 'cluster', id: 'g1', storage }); + await until(() => first.byClass('doxbench-textarea').length >= 2, 'the canvas'); + await quiesce(40); + for (const path of ['a.md', 'b.md']) { + const v = await expand(first, path); + await fire(v.load.node, 'click'); + await quiesce(60); + } + const areas = first.byClass('doxbench-textarea'); + const typed = areas[areas.length - 1]; + typed.value = typed.value + 'kept by the human\n'; + await fire(typed, 'input'); + await quiesce(40); + o.before = first.byClass('doxbench-textarea').filter((t) => !t.disabled).length; + first.workbench.close(); + await quiesce(20); + const regenerated = JSON.parse(JSON.stringify(SNAP)); + regenerated.clusters.find((c) => c.id === 'g1').document_edges = + [{ document: 'c.md' }]; + const again = await mount({ caps: STANDALONE, gate: { create: null, session: null }, + kind: 'cluster', id: 'g1', storage, + snapshot: regenerated }); + await until(() => again.byClass('doxbench-textarea').length >= 2, 'the canvas'); + await quiesce(80); + Object.assign(o, survey(again)); + o.live = again.byClass('doxbench-textarea').filter((t) => !t.disabled) + .map((t) => String(t.value)); + o.typedKept = o.live.some((v) => v.endsWith('kept by the human\n')); + o.cleanGone = !o.live.some((v) => v.startsWith('# a.md')); +}); + +// ---- S8: STANDALONE, the outline tab of a selection: no outline buffer by +// scope, so the add-section controls state why (Copilot review of #81, +// r4173502649) instead of offering an insert with nowhere to land. +await scenario('s8', async (o) => { + const asked = []; + const previous = globalThis.fetch; + globalThis.fetch = async (url) => { + asked.push(String(url)); + return { ok: true, status: 200, headers: { get: () => 'aligned' }, + text: async () => '# s1\n\n## Problem\n\nwhy\n' }; + }; + try { + const ctx = await mount({ caps: STANDALONE, gate: { create: null, session: null }, + kind: 'staged', id: 's1' }); + const tab = ctx.byClass('swb-tab').find( + (b) => String(b.textContent).toLowerCase().includes('outline')); + await fire(tab, 'click'); + await until(() => ctx.byClass('swb-outlineadd').length > 0, 'the add controls'); + await quiesce(20); + o.pill = survey(ctx).pill; + o.controls = ctx.byClass('swb-outlineadd').map((b) => ({ + disabled: b.disabled === true, title: String(b.title), + listeners: ((b.listeners || {}).click || []).length })); + o.fetched = asked; + } finally { + globalThis.fetch = previous; + } +}); + +// ---- S9: A HOST THAT REGISTERS ONLY THE SESSION HALF (Copilot review of #81, +// r4174293974): app.js sends its Save through that column's transport, so it +// is governed, not standalone. With no create column its gate reads off, so it +// is read-only exactly as before T102, even with `edit` true. +await scenario('s9', async (o) => { + const ctx = await mount({ caps: STANDALONE, + gate: { create: null, session: contributedGate().column.session }, + kind: 'cluster', id: 'g1' }); + Object.assign(o, survey(ctx)); +}); + +out.notes = { scope: model.scopeEditingNote(), nothing: model.nothingEditableNote(), + gateless: model.GATELESS_SAVE_REFUSAL }; +console.log(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def shell(tmp_path_factory): + return _stage(tmp_path_factory, "edit-by-scope-shell", _SHELL_HARNESS) + + +def _ran(shell: dict, name: str) -> dict: + """One scenario's record, refusing loudly if it could not complete.""" + record = shell[name] + assert "error" not in record, f"scenario {name} did not complete: {record['error']}" + return record + + +GATE_OFF_NOTE = GATE_OFF["note"] + + +def test_a_standalone_workbench_offers_both_editors_and_the_rail(shell) -> None: + """AT-R1 step 7's precondition: on a standalone install (no gate column, + `edit: true`) a group's workbench mounts the canvas AND the chat rail.""" + s1 = _ran(shell, "s1") + assert s1["canvasHidden"] is False + assert s1["textareas"] == 2, "the outline buffer and the document buffer" + assert s1["railHidden"] is False + assert s1["composer"] == 1 and s1["send"] == 1 + assert s1["sendDisabled"] is True, "no model is configured, so no turn is sent" + assert s1["turns"] == 0 + + +def test_the_standalone_pill_and_note_state_the_by_scope_posture(shell) -> None: + s1 = _ran(shell, "s1") + assert s1["pill"] == "editing by scope" + assert "Save is refused by name" in s1["pillTitle"] + assert shell["notes"]["scope"] in s1["note"] + assert "read-only" not in s1["note"] + + +def test_creating_a_document_and_generating_an_abstract_stay_behind_the_gate( + shell) -> None: + """Create mounts nothing without a column, as before; the abstract's + GENERATE control is absent rather than present-and-refusing, because ruling + 7.7 keys it on the gate and the route refuses at its step 1 without it.""" + s1 = _ran(shell, "s1") + assert s1["createButtons"] == 0 + assert s1["generate"] == 0 + assert s1["abstracts"] == 0 + + +def test_the_tiles_own_document_loads_for_editing(shell) -> None: + s1 = _ran(shell, "s1") + assert s1["loadB"] == {"disabled": False, + "title": "load b.md into the chat context for editing"} + assert s1["textareasAfterLoad"] == 3 + assert "loaded for editing" in s1["verbNote"] + + +def test_standalone_save_is_present_and_refused_by_name(shell) -> None: + """RULED `5963618568`: "only creating documents and Save stay behind the + gate, so Save is refused by name". Visible, pressed, and answered with the + sentence that names the gate, from the canvas and from the docs tile, and + the human's text stays in the buffer.""" + s1 = _ran(shell, "s1") + assert s1["canvasSaveRefusedByName"] is True + assert s1["typedSurvives"] is True + assert s1["tileSave"]["disabled"] is False + assert "the governed Save did not land" in s1["tileSaveNote"] + assert shell["notes"]["gateless"] in s1["tileSaveNote"] + assert "`gate.workbench.session`" in shell["notes"]["gateless"] + + +def test_the_save_refusal_names_only_the_missing_session_transport(shell) -> None: + """Copilot review of #81 (r4174293950): `app.js` picks the refusal + transport on the SESSION half alone, so a host with a live create column + and no session column reaches the same sentence. It must name the + first-edit transport and never claim the install has no create gate.""" + gateless = shell["notes"]["gateless"] + assert gateless.startswith("Save needs the first-edit transport") + assert "`gate.workbench.session`" in gateless + assert "create" not in gateless + assert gateless.endswith("Your edits stay in this browser's buffers, unsaved.") + + +def test_the_edit_verb_is_offered_on_exactly_the_scopes_documents(shell) -> None: + """A candidate's citations are context; its claiming groups' members are + its own. `both.md` is both, and the scope's answer wins.""" + verbs = _ran(shell, "s2")["verbs"] + assert verbs["cited.md"]["load"]["disabled"] is True + assert verbs["cited.md"]["load"]["title"] == ( + "this document is context in the opened tile, not one of the tile's " + "own, so it is not offered for editing here") + assert verbs["cited.md"]["save"]["disabled"] is True + assert "read-only context" in verbs["cited.md"]["save"]["title"] + for own in ("both.md", "a.md"): + assert verbs[own]["load"]["disabled"] is False, own + + +def test_a_tile_with_nothing_of_its_own_stays_read_only_and_says_why(shell) -> None: + s3 = _ran(shell, "s3") + assert s3["canvasHidden"] is True and s3["textareas"] == 0 + assert s3["railHidden"] is True and s3["composer"] == 0 + assert s3["pill"] == "read-only" + assert s3["note"] == shell["notes"]["nothing"] + assert s3["load"]["disabled"] is True + assert "editing capability" in s3["load"]["title"] + + +def test_without_the_edit_capability_a_standalone_workbench_is_read_only( + shell) -> None: + s4 = _ran(shell, "s4") + assert s4["canvasHidden"] is True and s4["textareas"] == 0 + assert s4["railHidden"] is True + assert s4["pill"] == "read-only" + assert s4["note"] == GATE_OFF_NOTE + + +def test_a_governed_host_with_the_gate_live_is_exactly_as_before(shell) -> None: + """The gate decides: the gate pill, the canvas and the rail, the generate + control, and EVERY document loadable, a citation included.""" + s5 = _ran(shell, "s5") + assert s5["pill"] == "gate: create-document" + assert s5["canvasHidden"] is False and s5["textareas"] >= 2 + assert s5["railHidden"] is False + assert s5["generate"] == 1 + assert s5["loadCited"]["disabled"] is False + assert shell["notes"]["scope"] not in s5["note"] + + +def test_a_governed_host_with_the_gate_off_stays_read_only(shell) -> None: + """Even with `edit: true`: the by-scope arm is openDox's own default, never + a host's, so a governed host whose gate is off reads exactly as before.""" + s6 = _ran(shell, "s6") + assert s6["canvasHidden"] is True and s6["textareas"] == 0 + assert s6["railHidden"] is True + assert s6["pill"] == "read-only" + assert s6["note"] == GATE_OFF_NOTE + + +def test_a_restored_buffer_the_scope_no_longer_owns_is_reconciled(shell) -> None: + """Copilot review of #81 (r4173470792): the restore is the one route into + the loaded set that bypasses the scope, so it is reconciled with the + projection once the canvas is ready. The clean buffer leaves; the dirty + one stays, because unloading it would lose the human's text, and the + posture note names it and says what to do.""" + s7 = _ran(shell, "s7") + assert s7["before"] == 3, "the outline buffer plus both loaded members" + assert s7["cleanGone"] is True, s7["live"] + assert s7["typedKept"] is True, s7["live"] + assert len(s7["live"]) == 2, "the outline buffer and the kept dirty one" + assert "no longer one of this tile's own" in s7["note"] + assert "b.md" in s7["note"] and "a.md" not in s7["note"] + assert "a chat turn that carries it is refused" in s7["note"] + assert s7["pill"] == "editing by scope" + + +def test_by_scope_the_outline_tab_states_why_no_section_is_added(shell) -> None: + """Copilot review of #81 (r4173502649): the neutral scope projects no + outline, so there is no outline buffer for an insert to land in. Every + add-section control is inert, with no listener, and says so.""" + s8 = _ran(shell, "s8") + assert s8["pill"] == "editing by scope" + assert s8["fetched"] == ["/source/sel.md"] + assert s8["controls"], "the tab mounted its add-section controls" + for control in s8["controls"]: + assert control["disabled"] is True + assert control["listeners"] == 0 + assert control["title"].startswith( + "adding a section writes into an outline buffer"), control["title"] + assert "edit verb" in control["title"] + + +def test_a_host_with_only_the_session_half_is_governed_and_read_only(shell) -> None: + """Copilot review of #81 (r4174293974): either contributed gate half makes + the install governed, so the by-scope arm is limited to installs with no + gate column at all. Session-only, the create gate reads off: read-only, + with the governed host's old note, and no Save offered.""" + s9 = _ran(shell, "s9") + assert s9["canvasHidden"] is True and s9["textareas"] == 0 + assert s9["railHidden"] is True + assert s9["pill"] == "read-only" + assert s9["note"] == GATE_OFF_NOTE + + +def test_app_composes_the_standalone_save_from_the_named_refusal() -> None: + """The shell harness above composes the standalone Save as `app.js` does; + this holds `app.js` to that composition.""" + app = (WEB / "app.js").read_text(encoding="utf-8") + assert ('import { CONSOLE_TOKEN_FIELD, GATELESS_SAVE_REFUSAL } from ' + '"./views/staging-workbench-model.js";') in app + transport = app.split("function refusalTransport() {", 1)[1].split("\n}\n", 1)[0] + assert 'error: "no_gate_column",' in transport + assert "message: GATELESS_SAVE_REFUSAL," in transport + assert ": refusalTransport()," in app + assert "pinned checkout" not in transport + + +# =========================================================================== +# G8: the display text no longer names a host's surfaces +# =========================================================================== + +def test_the_lens_and_the_about_text_name_no_host_surface() -> None: + lens = (VIEWS / "lens.js").read_text(encoding="utf-8") + for gone in ('"The tested engine (lens.py) materialises', + '"Persisted through the interactivity boundary to "', + "Nothing enters the register or any queue from here."): + assert gone not in lens, gone + assert ('"Shown for confirmation only: this console cannot carry the plan ' + 'out "') in lens + assert ('"Each button shows its plan below before anything is written; this "' + in lens) + index = (WEB / "index.html").read_text(encoding="utf-8") + assert "ideation governance state" not in index + assert "A projection of one repository's Markdown files and how they group," in index + wheel = (VIEWS / "wheel.js").read_text(encoding="utf-8") + assert '" (read-only)",' not in wheel + assert "? vocab.one(opts.wheelKey) : \"tile\")," in wheel