// The only way this page talks to the system.
//
// TWO RULES, and the second is the one the frontend handoff warns about.
//
//   1. Every call carries only the server-side session cookie and nothing else
//      about identity. JavaScript cannot read that HTTP-only credential. The
//      browser never sends a role or actor; the service resolves both.
//
//   2. NO PANE FETCHES BROADLY AND FILTERS ON SCREEN. That is the named leak —
//      data a role may not see arriving in the browser and being hidden by
//      JavaScript is not a control, it is a control-shaped decoration. Every
//      function below maps to one role-scoped endpoint. If a workspace needs a
//      narrower slice, that is a read model request on the backend.
//
// There is deliberately no generic `get(path)` exported for a pane to call with
// whatever it likes. The endpoint list is right here, it is short, and adding to
// it is a visible act.

const API = (() => {
  // Same origin. The service serves this page, so there is no base URL to
  // configure and no cross-origin rule to relax.
  const base = '/api';
  const sessionRoles = new Set([
    'requester', 'legal_reviewer', 'legal_admin',
    'auditor', 'viewer', 'administrator', 'procurement',
  ]);
  let identity = null;

  // WHAT TO DO WITH A 401 THE MOMENT IT ARRIVES.
  //
  // `expired: res.status === 401` was already computed at five places below
  // and read by nothing. The only thing that returned to the front door was a
  // thirty-second poll of /me in main.jsx — so the refusal already in the
  // reader's hand was discarded and the app waited for a timer to go and ask
  // the same question again. Measured window: nought to thirty seconds of a
  // masthead saying "desk of Ada Okafor — administrator's grant" after the
  // service had said there is no session.
  //
  // Registered here rather than checked at each call site, because the fact
  // and the reaction then live at the same place and cannot come apart. Fired
  // once per loss: `identity` is cleared by the handler, so a burst of parallel
  // reads all returning 401 raises the front door once.
  //
  // signIn() below does NOT route through call(), so a refused sign-in never
  // reaches this — which is right. Being refused a session is not losing one,
  // and it already renders its own sentence.
  let expiredHandler = null;
  function noteExpired(status) {
    if (status !== 401 || !identity || !expiredHandler) return;
    const fire = expiredHandler;
    queueMicrotask(() => fire());
  }

  const unreachable = () => ({
    ok: false, status: 0, reason: 'the service could not be reached',
    unreachable: true,
  });
  const unreadable = (status) => ({
    ok: false, status, reason: 'the service returned an unreadable response',
    invalidResponse: true,
  });
  const failureReason = (payload, fallback) => {
    for (const candidate of [payload?.reason, payload?.error]) {
      if (typeof candidate === 'string' && candidate.trim()) return candidate;
    }
    return fallback;
  };

  async function call(method, path, body) {
    let res;
    try {
      res = await fetch(base + path, {
        method,
        credentials: 'same-origin',
        headers: {
          'content-type': 'application/json',
        },
        body: body === undefined ? undefined : JSON.stringify(body),
      });
    } catch { return unreachable(); }
    noteExpired(res.status);
    let payload;
    try { payload = await res.json(); }
    catch { return res.ok ? unreadable(res.status) : {
      ok: false, status: res.status,
      reason: `the request failed (${res.status})`,
      expired: res.status === 401,
    }; }

    if (res.ok && (payload === null || typeof payload !== 'object'
        || Array.isArray(payload))) return unreadable(res.status);
    if (res.ok && Object.prototype.hasOwnProperty.call(payload, 'rows')
        && !Array.isArray(payload.rows)) return unreadable(res.status);

    const sessionEnded = res.status === 401
      || (res.status === 403 && payload?.session_ended === true);
    if (sessionEnded && res.status !== 401) noteExpired(401);

    if (res.ok) return { ok: true, rows: payload?.rows ?? [], body: payload };

    // A refusal is passed up as what it is, with the DATABASE's own sentence.
    // Those sentences name the rule and the role — "X is an owner decision and
    // only a legal admin may change it" — and replacing one with "You do not
    // have permission" would throw away the only part a person can act on.
    return {
      ok: false,
      status: res.status,
      reason: failureReason(payload, `the request failed (${res.status})`),
      expired: sessionEnded,
    };
  }

  // A reply that is a FILE rather than a record.
  //
  // THIS FUNCTION OWNS TRANSPORT AND REFUSAL-SHAPING, AND NOTHING ELSE. It
  // fetches, lets the browser carry its HTTP-only cookie, reads the bytes and
  // the filename, and turns a
  // non-ok reply into exactly the shape `call` returns — so a refused download
  // arrives as a sentence somebody can read rather than as a broken file on
  // their desktop.
  //
  // IT DOES NOT SAVE THE FILE. Building the anchor, making the object URL,
  // setting the download name and revoking it afterwards all belong to the ONE
  // screen that downloads, and that split is checked rather than trusted:
  // db/test/shell.test.mjs asserts this file contains no createElement('a'),
  // and that only the requester's screen names the helper below. ADR-0008 gave
  // the viewer no export path, and it survives precisely because the saving
  // step lives in one screen instead of in the transport every screen uses.
  async function download(path) {
    let res;
    try {
      res = await fetch(base + path, {
        credentials: 'same-origin',
      });
    } catch { return unreachable(); }
    noteExpired(res.status);
    if (!res.ok) {
      const payload = await res.json().catch(() => null);
      const sessionEnded = res.status === 401
        || (res.status === 403 && payload?.session_ended === true);
      if (sessionEnded && res.status !== 401) noteExpired(401);
      return {
        ok: false,
        status: res.status,
        reason: failureReason(payload, `the request failed (${res.status})`),
        expired: sessionEnded,
      };
    }
    const disposition = res.headers.get('content-disposition') || '';
    const named = /filename="([^"]+)"/.exec(disposition);
    let blob;
    try { blob = await res.blob(); }
    catch { return unreachable(); }
    return { ok: true, blob, filename: named ? named[1] : 'contract.docx' };
  }

  // A request whose BODY is a document rather than a record — the mirror of
  // `download`, and the third and last shape this transport knows.
  //
  // The file is sent as raw bytes with its own content type, NOT as multipart
  // form data, because that is the shape the service reads: server.py decides
  // "this is a document" from the content type not being JSON, and takes the
  // claimed name from a content-disposition header. Wrapping it in a form
  // would arrive as a document whose bytes are a MIME envelope, and the
  // fingerprint the schema computes would be the fingerprint of the envelope.
  //
  // The name is sent exactly as the person's own file carried it. This layer
  // does not sanitise it — the service refuses the names it cannot accept, in
  // words, which is better than two layers quietly disagreeing about what the
  // file was called.
  async function send(path, file) {
    let res;
    try {
      res = await fetch(base + path, {
        method: 'POST',
        credentials: 'same-origin',
        headers: {
          'content-type': file.type || 'application/octet-stream',
          'content-disposition':
            `attachment; filename="${String(file.name || '').replace(/["\r\n]/g, '')}"`,
        },
        body: file,
      });
    } catch { return unreachable(); }
    let payload;
    try { payload = await res.json(); }
    catch { return res.ok ? unreadable(res.status) : {
      ok: false, status: res.status,
      reason: `the upload failed (${res.status})`,
      expired: res.status === 401,
    }; }
    if (res.ok && (payload === null || typeof payload !== 'object'
        || Array.isArray(payload))) return unreadable(res.status);
    if (res.ok) return { ok: true, body: payload };
    const sessionEnded = res.status === 401
      || (res.status === 403 && payload?.session_ended === true);
    if (sessionEnded && res.status !== 401) noteExpired(401);
    return {
      ok: false,
      status: res.status,
      reason: failureReason(payload, `the upload failed (${res.status})`),
      expired: sessionEnded,
    };
  }

  function sessionIdentity(payload) {
    if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
      return null;
    }
    if (![payload.person, payload.role, payload.display_name].every(
        (value) => typeof value === 'string' && value.trim())) return null;
    if (!sessionRoles.has(payload.role)) return null;
    if (payload.unit !== null && payload.unit !== undefined
        && typeof payload.unit !== 'string') return null;
    return {
      person: payload.person, role: payload.role,
      display_name: payload.display_name, unit: payload.unit,
    };
  }

  return {
    get session() { return identity; },
    get signedIn() { return identity !== null; },

    async authStatus() {
      return call('GET', '/auth/status');
    },

    async restoreSession() {
      let res;
      try {
        res = await fetch(base + '/session-status', { credentials: 'same-origin' });
      } catch { return unreachable(); }
      if (!res.ok) return { ok: false, status: res.status };
      let payload;
      try { payload = await res.json(); }
      catch { return unreadable(res.status); }
      const checked = sessionIdentity(payload);
      if (!checked) return unreadable(res.status);
      identity = checked;
      return { ok: true, identity };
    },

    async signIn(person) {
      let res;
      try {
        res = await fetch(base + '/sign-in', {
          method: 'POST',
          credentials: 'same-origin',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ person }),
        });
      } catch { return unreachable(); }
      let payload;
      try { payload = await res.json(); }
      catch { return res.ok ? unreadable(res.status) : {
        ok: false, status: res.status, reason: 'sign-in failed',
      }; }
      if (res.ok && (payload === null || typeof payload !== 'object'
          || Array.isArray(payload))) return unreadable(res.status);
      if (!res.ok) return { ok: false, reason: failureReason(payload, 'sign-in failed') };
      identity = sessionIdentity(payload);
      if (!identity) return unreadable(res.status);
      return { ok: true, identity };
    },

    async signOut() {
      if (!identity) return { ok: true };
      const result = await call('POST', '/sign-out');
      // JavaScript cannot clear the HTTP-only credential. Claiming success
      // while the service is unreachable would put up the front door even
      // though a reload silently reopened the same session. Stay at the desk
      // and say what happened until the server confirms revocation and clears
      // its cookie.
      if (result.ok) identity = null;
      return result;
    },

    // Called when any request comes back 401. The session is gone — expired, or
    // the person was revoked — and the honest thing is to return to the front
    // door rather than leave a workspace on screen that can no longer load.
    forget() { identity = null; },

    // Registered once by the app. Called as soon as ANY reply is a 401, so the
    // front door comes back on the refusal itself rather than on the next tick
    // of a timer.
    onExpired(fn) { expiredHandler = fn; },

    // ── Reads. One function, one role-scoped endpoint. ──────────────────
    // The shell's quiet revocation/expiry poll is the sole caller. The query
    // tells the service to verify both clocks without counting the timer as
    // activity; otherwise an abandoned browser would keep itself alive.
    me:               () => call('GET', '/me?activity=check'),
    deals:            () => call('GET', '/deals'),
    // ── Suppliers (0111) ────────────────────────────────────────────
    // Four reads and five acts. `supplierDeals` deliberately returns the
    // UNRESOLVED rows too — a supplier page that quietly dropped them would
    // answer "nothing else is affected" when it means "we did not look".
    suppliers:          () => call('GET', '/suppliers'),
    supplierAliases:    () => call('GET', '/suppliers/aliases'),
    supplierDeals:      () => call('GET', '/suppliers/deals'),
    supplierUnresolved: () => call('GET', '/suppliers/unresolved'),
    // ── Cross-contract impact (0114) ────────────────────────────────
    // WHAT A BUILD WOULD DO, before anybody runs it. Mike's decision: the
    // bulk load is a NAMED act that shows the paragraph count BEFORE it runs.
    // This is the half that shows the count; it contacts no provider and
    // spends nothing, so it can be looked at as often as anybody likes.
    crossContractPlan: (supplierId) => call('GET',
      '/cross-contract/plan?supplier=' + encodeURIComponent(supplierId)),
    // THE WARNING, for one deal. Both the requester and Legal call this and
    // get different rows from the same statement, because the view is fenced
    // on both sides by the deals each may read. `at_least` is the nearness
    // the caller wants; the doorway states the default in one place and the
    // reply carries back the number it used.
    crossContractEchoes: (agreementId, atLeast) => call('GET',
      '/cross-contract/echoes?agreement=' + encodeURIComponent(agreementId)
      + (atLeast === undefined || atLeast === null
           ? '' : '&nearness=' + encodeURIComponent(atLeast))),
    waitingTickets:   () => call('GET', '/waiting/tickets'),
    countersignQueue: () => call('GET', '/waiting/countersign'),
    people:           () => call('GET', '/people'),
    peopleActivity:   () => call('GET', '/people/activity'),
    accessSummary:    () => call('GET', '/people/summary'),
    accessHistory:    () => call('GET', '/access-history'),
    settings:         () => call('GET', '/settings'),
    contractSources: (search = '') => call('GET', '/contract-onboarding/sources?key=' + encodeURIComponent(search)),
    contractTicketSource: (id) => call('GET', '/contract-onboarding/ticket?document=' + encodeURIComponent(id)),
    historicalObligations: () => call('GET', '/contract-onboarding/obligations'),
    declareHistoricalObligation: (body) => call('POST', '/contract-onboarding/obligations/declare', body),
    completeHistoricalObligation: (body) => call('POST', '/contract-onboarding/obligations/complete', body),
    contractSource: (id) => call('GET', '/contract-onboarding/source?document=' + encodeURIComponent(id)),
    contractOriginal: (id) => download('/contract-onboarding/original?document=' + encodeURIComponent(id)),
    uploadContractSource: (purpose, file) => send('/contract-onboarding/upload?key=' + encodeURIComponent(purpose), file),
    transcribeContractSource: (purpose, id, file) => send('/contract-onboarding/upload?key=' + encodeURIComponent(purpose) + '&document=' + encodeURIComponent(id), file),
    reviewContractSource: (body) => call('POST', '/contract-onboarding/review', body),
    fileContractSource: (body) => call('POST', '/contract-onboarding/file', body),
    onboardingSchema: () => call('GET', '/onboarding/schema'),
    onboardingPlans:  () => call('GET', '/onboarding/plans'),
    onboardingTemplate: () => download('/onboarding/template'),
    categories:       () => call('GET', '/categories'),
    health:           () => call('GET', '/health'),
    // THE EVIDENCE UNDER EACH TILE (0013, reached 2026-08-23). Five views
    // and a table the Administrator and the Auditor have been granted since
    // 0013, and which nothing in the service layer had ever named — so the
    // page that asserts the record is intact could show none of its working.
    healthChain:      () => call('GET', '/health/chain'),
    healthCheckpoints:() => call('GET', '/health/checkpoints'),
    healthDocuments:  () => call('GET', '/health/documents'),
    healthNotifications: () => call('GET', '/health/notifications'),
    healthRebuild:    () => call('GET', '/health/rebuild'),
    healthChecks:     () => call('GET', '/health/checks'),
    watchers:         () => call('GET', '/watchers'),
    watcherCoverage:  () => call('GET', '/watchers/coverage'),
    tickets:          () => call('GET', '/tickets'),
    // THE EVIDENCE BEHIND A TICKET (0008, reached 2026-08-24). Both tables
    // were on both censuses: nothing wrote them and nothing read them, so
    // for sixteen migrations the desk showed the quarantined text alone.
    // Whole-set reads scoped by the TICKET's own policy, so a pane filtering
    // by ticket_id is narrowing what it draws, never widening what it sees.
    ticketSegments:   () => call('GET', '/tickets/segments'),
    ticketCandidates: () => call('GET', '/tickets/candidates'),
    // 0107. The proposals that are NOT wording — a ladder rung or a
    // validation rule the Library Builder drafted — with, for a rule,
    // whether its predicate is even legal in the three-primitive grammar.
    structuralProposals: () => call('GET', '/structural-proposals'),
    quality:          () => call('GET', '/quality'),
    // THE RETAINED-LANGUAGE FIGURE (NC-13, migration 0048), three cuts.
    //
    // ALL THREE WERE SERVED BY THE DOORWAY AND OFFERED BY NOTHING until
    // 2026-08-23. The unedited-approval rate beside them says how OFTEN an
    // approval went through untouched; these say how much of the machine's
    // wording survived when somebody DID edit — the other half of ADR-0010's
    // question, computed since 0048 and on no screen.
    //
    // `measured` is the count the mean and the minimum were computed over
    // (0101). It equals `verified` on any database built from scratch and is
    // smaller wherever rows verified before 0029 survive; the screen draws the
    // difference only when there is one.
    editQuality:           () => call('GET', '/quality/edit'),
    editQualityByCategory: () => call('GET', '/quality/edit/by-category'),
    editQualityByAgreement:() => call('GET', '/quality/edit/by-agreement'),
    originMix:        () => call('GET', '/origin-mix'),
    originMixRuns:    () => call('GET', '/origin-mix/runs'),
    clauses:          () => call('GET', '/clauses'),
    clauseVersions:   () => call('GET', '/clause-versions'),
    // The tags a conflict rule is evaluated against (0004, reached
    // 2026-08-24). engine/loader.py has read them onto every clause since
    // 0004 and no screen ever showed which versions carried which namespace.
    clauseTags:       () => call('GET', '/clause-tags'),
    entrance:         () => call('GET', '/entrance'),
    concessions:      () => call('GET', '/concessions'),
    holds:            () => call('GET', '/holds'),
    // A STATEMENT OF WORK THAT DEPARTS FROM ITS MASTER (0006/0012/0019,
    // reached 2026-08-23). Six objects with policies, an append-only
    // guarantee, an audit trigger and a GATE — and no read and no write in
    // the whole service layer, so the gate refused departures nobody could
    // authorise.
    attorneys:        () => call('GET', '/governance/attorneys'),
    // WHO MUST SIGN OFF (0010, reached 2026-08-24). The other half of the
    // same locked door: the gates above join this list to work out who has
    // not signed yet, and with nothing able to add a row it was always empty,
    // so both gates always passed on this side.
    requiredApprovers: () => call('GET', '/governance/required-approvers'),
    sowOverrides:     () => call('GET', '/sow/overrides'),
    sowApprovals:     () => call('GET', '/sow/approvals'),
    sowConflicts:     () => call('GET', '/sow/conflicts'),
    sowOrphans:       () => call('GET', '/sow/orphans'),
    overrides:        () => call('GET', '/overrides'),
    overrideFindings: () => call('GET', '/overrides/findings'),
    overrideNotified: () => call('GET', '/overrides/notified'),
    overrideSocialisation: () => call('GET', '/overrides/socialisation'),
    retentionDue:     () => call('GET', '/retention/due'),
    record:           () => call('GET', '/record'),

    // ── The reading room (WP-U14) ───────────────────────────────────────
    // NEITHER TAKES A PARAMETER, and that is the control rather than an
    // oversight. WP-U14's critical anti-pattern is the viewer's render being
    // fetched through anything broader than "this share, this person" — and an
    // agreement_id argument is precisely how that happens: the moment the
    // browser can name what it wants, the scoping is a careful query rather
    // than a rule. cw.reading_room scopes itself in its own WHERE clause, from
    // the connection's identity, so there is nothing here to pass.
    //
    // THERE IS NO EXPORT HERE AND THERE MUST NOT BE. ADR-0008 gave the viewer
    // no export deliberately: the reading room shows a contract to somebody
    // outside the deal, and letting them take a copy away is a different act
    // nobody decided. 0017 leaves nothing in the schema for one to call and the
    // doorway asserts no such route exists. Convenience does not amend an ADR.
    readingRoom:        () => call('GET', '/reading-room'),
    readingRoomClauses: () => call('GET', '/reading-room/clauses'),
    // The share RECORD, including what was withdrawn — which the reading
    // room itself cannot show, because that view holds only live shares.
    // `0017` granted six roles select on the table and nothing served it.
    shares:             () => call('GET', '/shares'),

    // ── Notices ─────────────────────────────────────────────────────────
    notices:         () => call('GET', '/notices'),
    // People being waited on whom no channel can reach. It has existed since
    // OB-09 with no screen behind it — a fact the record held and nobody was
    // looking at.
    notificationGap: () => call('GET', '/notifications/gap'),
    // WHERE EACH PERSON'S NOTICES GO. The gap read above says who cannot be
    // reached; this says where everybody else can be, which is the only way
    // an address that was just set can be read back. Carries removed rows
    // too, so "never had one" and "somebody took it off" stay distinguishable.
    notificationAddresses: () => call('GET', '/notifications/addresses'),
    noticeRoutes:    () => call('GET', '/notice-routes'),

    // ── The negotiation record ──────────────────────────────────────────
    // SIX READS, NONE OF WHICH TAKES A PARAMETER, for the reading room's
    // reason: the scoping is "these deals, this person" and it is already on
    // the connection. A screen showing one negotiation filters what the rule
    // returned; it does not ask for one by name.
    // ── The deal room (0079) ────────────────────────────────────────────
    // Three reads, no parameters, the negotiation family's reason: the
    // scoping is on the connection, and the room's screen filters what the
    // rule already returned.
    // ── The expert panel (0090, ADR-0013) ───────────────────────────────
    // FIVE READS, NONE OF WHICH TAKES A PARAMETER, the reading room's reason
    // exactly: a panel member's rows are decided by their SEAT, which is on
    // the connection. The moment the browser could name a discipline, the
    // scoping would be a careful query rather than a rule.
    //
    // A screen showing one ticket's consultations filters what came back; it
    // does not ask for one by name.
    panelConsultations: () => call('GET', '/panel/consultations'),
    panelDisciplines:   () => call('GET', '/panel/disciplines'),
    panelRules:         () => call('GET', '/panel/rules'),
    panelSeats:         () => call('GET', '/panel/seats'),
    ticketConsultations: () => call('GET', '/tickets/consultations'),
    // What became of what the model suggested (0091). No parameter, the
    // reading room's reason: the scoping is on the connection.
    panelSuggestions:   () => call('GET', '/panel/suggestions'),
    panelUptake:        () => call('GET', '/panel/uptake'),
    // ── The panel's own measurements (0092, 0093) ────────────────────────
    // ADR-0013 promised these and 0090 built none of them; 0092 built three
    // and 0093 the fourth, and until now not one had a screen. They aggregate
    // across every deal, so the GRANT is the fence: the first three are the
    // Legal admin's and the Auditor's alone — the Administrator runs the
    // machine and reads no report, because a report is contract operations and
    // not operations of the machine (U5).
    //
    // `ticketsHeld` is the odd one out and scopes ITSELF in the view's WHERE,
    // so a requester is answered their own held tickets and Legal every one.
    panelLoad:          () => call('GET', '/panel/load'),
    panelWaivers:       () => call('GET', '/panel/waivers'),
    panelAgainstAdvice: () => call('GET', '/panel/against-advice'),
    ticketsHeld:        () => call('GET', '/tickets/held'),

    dealRoomComments:   () => call('GET', '/deal-room/comments'),
    dealRoomAnalyses:   () => call('GET', '/deal-room/analyses'),
    // The declared compliance concerns and the record of every check (0120).
    // Retired concerns travel too, with their retirement on them — a past
    // check references them by id and hiding one would make it unreadable.
    // The severity readings (0129): one row per model reading of one echo
    // pair, with the four-word label nothing may sort on.
    conflictAssessments:   () => call('GET', '/cross-contract/assessments'),
    complianceConcerns:    () => call('GET', '/compliance/concerns'),
    complianceAssessments: () => call('GET', '/compliance/assessments'),
    dealRoomVisits:     () => call('GET', '/deal-room/visits'),
    // The round's received Word file, parsed: paragraphs with tracked
    // changes in page order, and the supplier's comments out of the file.
    // ENCODED, like every other value this file puts in a URL. It was raw
    // interpolation until 2026-08-15 — harmless in itself, because both values
    // are database-generated numbers, and wrong as a pattern: three sibling
    // calls encode and this one did not, so the next one written would be a
    // coin toss. shell.test.mjs now holds all of them to the same rule.
    dealRoomDocument:   (negotiationId, roundNo) => call('GET',
      `/deal-room/document?negotiation=${encodeURIComponent(negotiationId)}`
      + `&round=${encodeURIComponent(roundNo)}`),

    // The editable working document (0130). Two whole-set reads, as
    // everywhere: no parameter, and the row rules decide what comes back.
    // Neither carries bytes — a list surface answers what exists, who touched
    // it and what it hashes to.
    workingDocuments:     () => call('GET', '/deal-room/working-documents'),
    workingDocumentSaves: () => call('GET', '/deal-room/working-document/saves'),

    negotiations:       () => call('GET', '/negotiations'),
    negotiationRounds:  () => call('GET', '/negotiations/rounds'),
    positions:          () => call('GET', '/negotiations/positions'),
    positionMovements:  () => call('GET', '/negotiations/movements'),
    revivals:           () => call('GET', '/negotiations/revivals'),
    renewalDrift:       () => call('GET', '/negotiations/drift'),
    roundAnalysis:      () => call('GET', '/negotiations/analysis'),
    // The two readers together (AI-5, 0103): what the word-counter decided,
    // and what a model made of the paragraphs it could not place. Each side
    // carries its own label out of the view, because the scales are not
    // comparable and a reader who takes them for the same kind of number is
    // the one way this feature does harm.
    paragraphReadings:  () => call('GET', '/negotiations/readings'),

    // AI-7 (0105). WHAT THE MACHINE WAS ASKED, and what it cost — the ledger
    // cw.model_call has written since 0066 and nothing could read. TWO reads
    // because a screen needs both at once: the aggregate is the question a
    // person arrives with, the record is what they drill into, and an
    // aggregate computed in the browser is one that disagrees with every
    // other copy of it.
    aiUse:              () => call('GET', '/ai/use'),
    aiUseByPurpose:     () => call('GET', '/ai/use/by-purpose'),
    // What the per-contract paragraph index paid for twice (0114).
    paragraphReuse:     () => call('GET', '/ai/use/paragraph-reuse'),
    // AI-6 (0104): what the machine proposed we send back, WITH the paragraph
    // it answers. One read, because a reviewer cannot judge a reply without
    // the ask, and two would be two lists that can disagree about which
    // paragraph a proposal belongs to. Scoped in the view, so a requester gets
    // their own negotiations and Legal gets every one.
    negotiationDrafts:  () => call('GET', '/negotiations/drafts'),
    riskAssessments:    () => call('GET', '/risk/assessments'),
    // What a lawyer's pen did to a machine's words, per ticket: the
    // DATABASE'S arithmetic and the MODEL'S opinion in the same row, each
    // carrying its own label out of the view (0030). The labels are columns
    // rather than sentences a screen remembers, because the one way this
    // feature does harm is a reader taking the two for the same kind of
    // thing. Written into the doorway on 2026-08-06 and served to nobody
    // until now.
    ticketMetrics:      () => call('GET', '/metrics'),
    // The authored playbook beside each open position (0081). No parameter,
    // the family's reason: cw.position_move scopes itself in its own WHERE
    // clause, and a screen narrows what the rule already returned.
    positionMoves:      () => call('GET', '/position-moves'),

    // ── Portfolio questions on our own paper (NC-16, 0049) ───────────────
    // "How many of our contracts carry this clause version" and "where could
    // the forge choose nothing at all" — computed by the database since
    // 2026-07-30 and served to NOBODY until 2026-08-24: these were the last
    // two reads in the doorway's table that this file could not call.
    //
    // NO PARAMETER ON EITHER, the family's reason: `cw.portfolio_run` scopes
    // itself in its own WHERE clause (0049), so a requester's numbers are
    // computed over runs they created or deals they own and Legal's over
    // every one. The same call answers each caller their own portfolio; a
    // screen narrows what the rule already returned and never widens it.
    portfolioPositions:  () => call('GET', '/portfolio/positions'),
    portfolioUnresolved: () => call('GET', '/portfolio/unresolved'),

    // ── A person's own rack (0108) ───────────────────────────────────────
    // The only pair in this list that reaches a row nobody else can read. No
    // person parameter on either: the doorway takes the actor from the
    // session, and the policies would refuse anything else.
    myWorkspace:    () => call('GET', '/me/workspace'),
    setMyWorkspace: (body) => call('POST', '/me/workspace', body),

    // ── The views a person has named (0110) ─────────────────────────────
    // The rack's neighbours, and the same property: private to the person,
    // no person parameter, the actor taken from the session.
    //
    // EVERY LIST'S VIEWS ARRIVE IN ONE ANSWER, because a workspace walk would
    // otherwise ask thirty-seven times to be told "none" thirty-five times.
    // `common.jsx` fetches it once per session and hands each list its own
    // slice by `list_key`.
    myViews:        () => call('GET', '/me/views'),
    saveMyView:     (body) => call('POST', '/me/views', body),
    forgetMyView:   (body) => call('POST', '/me/views/forget', body),

    // ── Reporting (RP-01…RP-04) ─────────────────────────────────────────
    // Management surfaces. The GRANT is the control: legal_admin and the
    // auditor get rows, everyone else gets the database's refusal, rendered
    // as the sentence it is. The friction scorecard is the exception — a
    // requester reads it at intake, on purpose.
    reportVelocity:    () => call('GET', '/reports/velocity'),
    reportContested:   () => call('GET', '/reports/contested'),
    reportQueue:       () => call('GET', '/reports/queue'),
    reportReviewers:   () => call('GET', '/reports/reviewers'),
    reportExposure:    () => call('GET', '/reports/exposure'),
    reportPolicyShift: () => call('GET', '/reports/policy-shift'),
    vendorFriction:    () => call('GET', '/vendors/friction'),
    ticketRoute:       () => call('GET', '/tickets/route'),

    // ── The library and the ladders (WP-U13) ────────────────────────────
    library:          () => call('GET', '/library'),
    ladders:          () => call('GET', '/ladders'),
    rules:            () => call('GET', '/rules'),
    // The negotiation-move board (0081): the whole playbook, live and
    // retired, with each fallback's rung context. Scoped by the view's own
    // WHERE clause — requester, both Legal roles and the auditor.
    moves:            () => call('GET', '/moves'),
    // The two 0003 analytics views. The GRANT is the whole control —
    // reviewer, admin and auditor get rows; every other role gets the
    // database's refusal, and the screen renders that sentence verbatim in
    // its own block rather than blanking a sibling or the pane.
    libraryProposals: () => call('GET', '/library/proposals'),
    concessionRates:  () => call('GET', '/concession-rates'),
    // THE EVIDENCE BEHIND A PROPOSAL. 0003's own comment on the view — "a
    // recommendation without its concessions is an assertion, and this system
    // does not make assertions" — and the panel's subtitle both promised this
    // and nothing delivered it until 2026-08-23. It carries each concession's
    // STATE, which the aggregate above silently drops: cw.concession_rate
    // counts cw.concession, not cw.concession_in_force.
    // SAME THREE READERS as the two above, and for a harder reason: the read
    // JOINS cw.library_proposal, so a role without that grant is refused the
    // whole statement rather than shown fewer rows.
    proposalEvidence: () => call('GET', '/library/proposal-evidence'),
    // WHERE THE LIBRARY IS THIN. `cw.coverage_gap` has been computed since
    // `0002` — the second migration ever written — with a grant to all six
    // roles and no endpoint until 2026-08-23. Every category crossed with both
    // severities, minus what the library can answer today; derived on every
    // read, so a retirement opens a gap immediately and an approval closes one.
    libraryCoverageGaps: () => call('GET', '/library/coverage-gaps'),
    // WHAT THE MACHINE PROPOSED, AND WHAT LEGAL DID ABOUT IT (0102). Every
    // AI-drafted candidate with what the model was shown, beside its ticket's
    // verdict and the edit-quality figure — the visibility ADR-0010 made the
    // price of letting a model draft at all.
    // THREE READERS, and the grant is the whole fence: legal_reviewer,
    // legal_admin, auditor. NOT a requester's screen — the view carries no
    // WHERE clause, so a requester is refused the whole statement rather than
    // shown their own drafts.
    libraryDrafts:    () => call('GET', '/library/drafts'),
    recordsDelegates: () => call('GET', '/records-delegates'),
    redactionState:   () => call('GET', '/redaction-state'),
    // The obligations surfaces (OB-11/OB-15). One derivation feeds the panel
    // and the digest, so screen and email cannot disagree.
    waiting:          () => call('GET', '/waiting'),
    // WHAT A CLAUSE OBLIGES (0035, reached 2026-08-24). The declarations
    // every row of the book below is derived from. 0035 built the whole
    // governed content type and no endpoint named the table, so with no
    // approved template no execution could ever register a duty.
    obligationTemplates: () => call('GET', '/obligation-templates'),
    obligationsBook:  () => call('GET', '/obligations'),
    obligationGaps:   () => call('GET', '/obligations/gaps'),
    // THE ABSENCE OF AN OWNER, RENDERED AS A GAP RATHER THAN AS CALM (0037).
    // A duty nobody holds is the one that goes past its date in silence, so
    // it is counted separately rather than left to be noticed in a list.
    obligationsUnowned: () => call('GET', '/obligations/unowned'),
    // NOBODY MARKS A DEAL CLOSED; the record says whether it is (0038), and an
    // unanchored survivor blocks — fail-closed. So this is a statement about
    // each agreement, never a button.
    closeableAgreements: () => call('GET', '/agreements/closeable'),
    // WHAT ARRIVED, WITHOUT ITS BYTES. `0050` lets an obligation act cite a
    // received document by id; until this read, nothing listed the ids, so the
    // act could not be performed by anybody who had not personally uploaded
    // the file and kept the number.
    receivedDocuments: () => call('GET', '/documents/received'),
    outbox:           () => call('GET', '/notifications/outbox'),
    envelopes:        () => call('GET', '/envelopes'),
    envelopeRecipients: () => call('GET', '/envelopes/recipients'),
    signatureCertificates: () => call('GET', '/signatures/certificates'),
    evidenceGaps:     () => call('GET', '/agreements/evidence-gaps'),
    agreementDrift:   () => call('GET', '/agreements/drift'),

    // ── Assembly runs ───────────────────────────────────────────────────
    // NONE OF THESE THREE TAKES A PARAMETER, for the reading room's reason:
    // the scoping is "these runs, this person" and it comes from the identity
    // on the connection. A screen that wants one run filters what the rule
    // already returned.
    runs:             () => call('GET', '/runs'),
    runDecisions:     () => call('GET', '/runs/decisions'),
    runFindings:      () => call('GET', '/runs/findings'),

    // ── Sourcing (0094, 0096, 0097, 0099 — SRC-1 through SRC-4) ─────────
    // NONE OF THESE FIVE TAKES A PARAMETER either, and for the same reason:
    // every one of them is scoped by the identity on the connection.
    //
    //   sections    the approved wording a sourcing document assembles from.
    //               Reference data — readable by anybody signed in, because a
    //               requester about to run an event should be able to read
    //               what suppliers will be told.
    //   runs        the documents that have been built, with the three
    //               provenance figures on every row.
    //   dossier     EVERY SPAN OF EVERY BUILT DOCUMENT, saying where it came
    //               from. This is the answer to "the AI wrote some of this —
    //               which parts?", and since 0099 its third arm carries the
    //               terms preview: the clause set the winner would sign, each
    //               clause with its version and its approver, and every term
    //               with no approved language marked `unpapered`.
    //   intents     whether each engagement is going to market at all.
    //   uncompeted  the register of the ones being handed over without
    //               competition, with the reason somebody gave. Legal, Audit
    //               and the Administrator hold the grant; a requester does
    //               not, and reads their own decision through `intents`.
    sourcingSections:   () => call('GET', '/sourcing/sections'),
    sectionHistory:     () => call('GET', '/sourcing/sections/history'),
    sourcingRuns:       () => call('GET', '/sourcing/runs'),
    sourcingDossier:    () => call('GET', '/sourcing/dossier'),
    sourcingIntents:    () => call('GET', '/sourcing/intents'),
    // ── The sourcing event (0123-0125, SRC-5) ────────────────────────────
    // The four reads that let a person see a competition: who we can write to,
    // the competitions themselves, who is in one, and which supplier holds
    // which version of the paper.
    supplierContacts:   () => call('GET', '/suppliers/contacts'),
    // Just the names. GET /suppliers carries deal counts off the agreement
    // family, which procurement holds no grant on — so a picker that used it
    // would be drawing a control onto a 403.
    supplierNames:      () => call('GET', '/suppliers/names'),
    sourcingEvents:     () => call('GET', '/sourcing/events'),
    sourcingInvitations:() => call('GET', '/sourcing/invitations'),
    sourcingIssues:     () => call('GET', '/sourcing/issues'),
    sourcingResponses:  () => call('GET', '/sourcing/responses'),
    sourcingEvaluations:() => call('GET', '/sourcing/evaluations'),
    sourcingComparison: () => call('GET', '/sourcing/comparison'),
    sourcingUncompeted: () => call('GET', '/sourcing/uncompeted'),

    // AND THIS ONE DOES, which is the exception and needs its argument made.
    // A run is the caller's OWN artefact, named by an id the server generated
    // and which the database's own rule already decided they may see — not a
    // share scoped by somebody else's identity, which is the case the reading
    // room refused. The endpoint resolves the id through the run table first
    // and treats "no such run of yours" as a refusal, so naming one you may
    // not have gets a sentence and no bytes.
    //
    // THIS IS NOT A PRECEDENT FOR THE READING ROOM. Those two still take no
    // argument and must not start.
    contract:  (runId) => download('/runs/contract?run=' + encodeURIComponent(runId)),

    // AND THE SOURCING DOCUMENT, on the same fence. `0096` keeps a fingerprint
    // and no bytes, so the service REBUILDS the document from the record and
    // refuses to serve anything that does not fingerprint to what is stored —
    // which means a file that arrives is provably the document the record
    // describes. It names a SOURCING RUN; that row's read policy decides, and
    // one that is not the caller's answers a sentence and no bytes.
    sourcingDocument: (runId) => download(
      '/sourcing/document?run=' + encodeURIComponent(runId)),

    // AND SO DOES THIS ONE, on the same argument and with the same fence.
    // Owner decision NI-4 (2026-08-05): the counterparty's document may be
    // read back by everyone who can read the deal. It names a ROUND, not a
    // document — the round's read policy decides, and a round that is not the
    // caller's answers a sentence and no bytes. The viewer still has no export
    // path of any kind (ADR-0008), and this does not become one.
    supplierPaper: (negotiationId, roundNo) => download(
      '/negotiations/paper?negotiation=' + encodeURIComponent(negotiationId)
      + '&round=' + encodeURIComponent(roundNo)),

    // ── Writes. Each one act. ───────────────────────────────────────────
    addCategory:   (b) => call('POST', '/categories', b),
    // The pre-flight and the act. THE PRE-FLIGHT HAS NEVER BEEN CALLED FROM
    // ANYWHERE until now — it has existed and been tested since the engine was
    // first connected, with no screen behind it — so this is the first time its
    // refusals are rendered to a person.
    // The deterministic intake (AI-1). The walk answers a RECORD — a version
    // and a set of probes — not a list of rows, which is why usePane learned
    // to hold a body.
    intakeProbes:   () => call('GET', '/intake/probes'),
    intakeClassifications: () => call('GET', '/intake/classifications'),
    classifyIntake: (b) => call('POST', '/intake/classify', b),
    checkManifest: (b) => call('POST', '/manifests/check', b),
    recordRun:     (b) => call('POST', '/runs', b),
    executeAgreement: (b) => call('POST', '/agreements/execute', b),
    sendForSignature: (b) => call('POST', '/agreements/send-for-signature', b),
    voidEnvelope:     (b) => call('POST', '/signatures/envelopes/void', b),
    pollSignatures:   () => call('POST', '/signatures/poll'),
    openDeal:      (b) => call('POST', '/deals', b),

    // ── The sourcing acts (SRC-2, SRC-3, SRC-4) ─────────────────────────
    //
    // TWO ACTS, AND THEY ARE NOT THE SAME KIND OF THING.
    //
    // `recordSourcingIntent` writes down WHETHER an engagement goes to market.
    // It is a Write — one statement — and it gates nothing: `none` carries a
    // reason because a direct award should be visible, never because anybody
    // has to approve it.
    //
    // `buildSourcing` assembles a document, and is deliberately NOT a Write:
    // a Write holds exactly one SQL statement, and a build is a library read,
    // an engine call and three inserts. What the caller supplies is the
    // ENGAGEMENT half only — the need, and the spans written for this deal.
    // The skeleton is not theirs to pick, and since SRC-4 neither is the
    // assembly the terms preview comes from: `terms_preview: true` says
    // WHETHER, and the record says WHICH.
    recordSourcingIntent: (b) => call('POST', '/sourcing/intent', b),
    buildSourcing:        (b) => call('POST', '/sourcing/build', b),

    // ── Curating the sourcing library (0111, issue #155) ──────────────────
    // 0094 granted the Legal admin insert and update on this library and no
    // endpoint ever wrote to it, so every section in a demo came from a seed
    // script — while ADR-0014 says the Legal admin curates it until the
    // `procurement` role exists.
    //
    // NOTE WHAT `publishSection` DOES NOT SEND: no `approved_by` and no
    // `version`. The approver is bound from the connection at the database and
    // the version is computed there, so neither is this client's to propose.
    // And no `title` on any of the three: retitling a section changes what a
    // rebuild of a past sourcing document produces, so no act offers it.
    addSection:      (b) => call('POST', '/sourcing/sections', b),
    publishSection:  (b) => call('POST', '/sourcing/sections/publish', b),

    // ── The seven acts that end "nothing leaves the building" ─────────────
    // NOTE WHAT NONE OF THESE SENDS: no `added_by`, `opened_by`, `invited_by`,
    // `issued_by`, `removed_by` or `withdrawn_by`. Six actor columns across
    // five tables, every one bound from the connection at the database and
    // named in NEVER_FROM_THE_BODY — so this client cannot propose who chose a
    // bidder or who sent the paper, even by accident.
    //
    // `issueDocument` sends no version number either: it is the database's, per
    // event, because a caller who could pick it could write issue 1 twice.
    addSupplierContact:    (b) => call('POST', '/suppliers/contacts', b),
    removeSupplierContact: (b) => call('POST', '/suppliers/contacts/remove', b),
    openSourcingEvent:     (b) => call('POST', '/sourcing/events', b),
    closeSourcingEvent:    (b) => call('POST', '/sourcing/events/close', b),
    inviteSupplier:        (b) => call('POST', '/sourcing/invitations', b),
    withdrawSupplier:      (b) => call('POST', '/sourcing/invitations/withdraw', b),
    issueDocument:         (b) => call('POST', '/sourcing/events/issue', b),
    // ISSUING AND DELIVERING ARE TWO ACTS, and the split is the product rather
    // than a wiring detail. Issuing says WHO IS TO RECEIVE WHICH VERSION and is
    // one statement in the database; delivering talks to a mail server, which
    // is an outside party that may refuse, and records what happened per
    // person. A single button that did both would have no honest answer when
    // the second half failed. `deliverIssue` names only the issue: who has not
    // had it yet is the record's to say, never the caller's.
    deliverIssue:          (b) => call('POST', '/sourcing/issues/deliver', b),
    recordSourcingResponse: (b) => call('POST', '/sourcing/responses', b),
    withdrawSourcingResponse: (b) => call('POST', '/sourcing/responses/withdraw', b),
    uploadSourcingResponse: (eventId, supplierId, issueId, summary, file) => send(
      '/sourcing/responses/upload?event=' + encodeURIComponent(eventId)
      + '&supplier=' + encodeURIComponent(supplierId)
      + '&issue=' + encodeURIComponent(issueId)
      + (summary ? '&summary=' + encodeURIComponent(summary) : ''), file),
    recordEvaluation:       (b) => call('POST', '/sourcing/evaluations', b),
    awardSourcingEvent:     (b) => call('POST', '/sourcing/events/award', b),
    retireSection:   (b) => call('POST', '/sourcing/sections/retire', b),
    // Tagging a clause version (0004, reached 2026-08-24). No untagging:
    // 0004 grants DELETE on cw.clause_tag to nobody, so removal is a
    // permission that was never given rather than a door left shut.
    tagClause:     (b) => call('POST', '/library/tag', b),
    openHold:      (b) => call('POST', '/holds', b),
    assignAttorney:       (b) => call('POST', '/governance/attorney', b),
    // NOTE THE FIELD NAMES: `must_approve`, not `approver`. Every other
    // person-name in this API comes from the connection; this one is a Legal
    // admin naming somebody ELSE who must sign off, which is the whole
    // content of the act. The doorway refuses a field called `approver`.
    addRequiredApprover:    (b) => call('POST', '/governance/required-approver', b),
    removeRequiredApprover: (b) => call('POST', '/governance/required-approver/remove', b),
    removeAttorney:       (b) => call('POST', '/governance/attorney/remove', b),
    proposeSowOverride:   (b) => call('POST', '/sow/overrides', b),
    approveSowOverride:   (b) => call('POST', '/sow/overrides/approve', b),
    authoriseSowOverride: (b) => call('POST', '/sow/overrides/authorise', b),
    openTicket:    (b) => call('POST', '/tickets', b),

    // ── The obligation acts (OB-07 over 0037/0039, OB-06 over 0050) ─────
    //
    // FOUR ACTS, FOUR ENDPOINTS, AND THEY ARE NOT INTERCHANGEABLE. Each is a
    // different claim about the same duty, and the schema refuses the ways
    // they could be confused:
    //
    //   satisfy    closes it. The note is MANDATORY — the attestation says
    //              what was done, and bytes without a sentence are not
    //              evidence anybody can act on. Evidence is optional.
    //   ack        records that the COUNTERPARTY acknowledged something. It
    //              is evidence, NOT closure: the duty stays open and the
    //              state view never reads this act. A document is mandatory,
    //              because an acknowledgement with no document is the bare
    //              flag OB-06 exists to refuse.
    //   reassign   hands it to a NAMED PERSON, never a team inbox.
    //   waive      closes it WITHOUT it being done, and is the only one that
    //              needs an outside authority: an override, socialised, its
    //              window run, decided by a Legal reviewer who did not open
    //              it, whose finding names `obligation:<id>` and was
    //              APPROVED. A proposal authorises nothing.
    //
    // ASSERTING BREACH IS ABSENT, AND DELIBERATELY. D-1: the system computes
    // and reports overdue, which is arithmetic; breach is a consequential
    // legal claim a person makes. There is no endpoint, so there is no method
    // here, and a screen cannot grow one by accident.
    // Declaring a duty on a clause version: three acts by two people, and
    // 0035 refuses an approval by the recorded proposer.
    proposeObligationTemplate: (b) => call('POST', '/obligations/templates', b),
    approveObligationTemplate: (b) => call('POST', '/obligations/templates/approve', b),
    retireObligationTemplate:  (b) => call('POST', '/obligations/templates/retire', b),
    satisfyObligation:  (b) => call('POST', '/obligations/satisfy', b),
    ackObligation:      (b) => call('POST', '/obligations/ack', b),
    reassignObligation: (b) => call('POST', '/obligations/reassign', b),
    waiveObligation:    (b) => call('POST', '/obligations/waive', b),

    // ── The expert panel's acts (0090, ADR-0013) ────────────────────────
    // Asking, answering and waiving are THREE calls for the reason verify and
    // reject are two: different acts, different authority. Collapsing them
    // would put all three behind whichever policy the merged statement
    // happened to satisfy.
    //
    // panelRoute is the model seam. It answers with `rule_routes` on EVERY
    // path — spent budget, dead provider, no key at all — so a screen never
    // has to decide what to show when the model is absent. It shows the rules,
    // and says in one sentence why there is nothing beside them.
    consult:       (b) => call('POST', '/panel/consult', b),
    answerConsultation: (b) => call('POST', '/panel/answer', b),
    waiveConsultation:  (b) => call('POST', '/panel/waive', b),
    panelRoute:    (b) => call('POST', '/panel/route', b),
    seatExpert:    (b) => call('POST', '/panel/seats', b),
    closeSeat:     (b) => call('POST', '/panel/seats/close', b),
    addConsultationRule: (b) => call('POST', '/panel/rules', b),
    // Taking a referral away is half of designing a workflow, and 0090
    // shipped only the half that adds.
    removeConsultationRule: (b) => call('POST', '/panel/rules/remove', b),
    verifyTicket:  (b) => call('POST', '/tickets/verify', b),
    // 0107. verifyTicket has ONE destination — it mints a clause version —
    // and refuses a ticket carrying a rung or a rule draft. These are where
    // those two go instead, and each ends at the thing the model proposed:
    // a position on the ladder, or a version of a validation rule.
    placeDraftedRung:  (b) => call('POST', '/tickets/place-rung', b),
    publishDraftedRule:(b) => call('POST', '/tickets/publish-rule', b),
    claimTicket:   (b) => call('POST', '/tickets/claim', b),
    releaseClaim:  (b) => call('POST', '/tickets/claim/release', b),
    rejectTicket:  (b) => call('POST', '/tickets/reject', b),
    createAccount: (b) => call('POST', '/accounts', b),
    revokeAccount: (b) => call('POST', '/accounts/revoke', b),
    grant:         (b) => call('POST', '/grants', b),
    countersign:   (b) => call('POST', '/grants/countersign', b),
    revokeGrant:   (b) => call('POST', '/grants/revoke', b),
    setSetting:    (b) => call('POST', '/settings', b),
    validateOnboarding: (document_text) => call('POST', '/onboarding/validate', { document_text }),
    planOnboarding:     (document_text) => call('POST', '/onboarding/plans', { document_text }),
    approveOnboarding:  (b) => call('POST', '/onboarding/plans/approve', b),
    applyOnboarding:    (b) => call('POST', '/onboarding/plans/apply', b),
    rollbackOnboarding: (b) => call('POST', '/onboarding/plans/rollback', b),
    decideSetting: (b) => call('POST', '/settings/decide', b),
    addWatcher:    (b) => call('POST', '/watchers', b),
    removeWatcher: (b) => call('POST', '/watchers/remove', b),
    // The address book, the other half of the Administrator's operational
    // upkeep. Built and permitted by 0042 and callable from nothing until
    // 2026-08-25: the tick read an address book only a seed script could
    // write, so a notice had nowhere to go for any real person.
    //
    // There is no `changeAddress`. The row refuses an edit by trigger — an
    // address is removed and a new one set, so the record says who chose each
    // one — and a method here that hid that behind one call would be the
    // browser pretending the schema is something it is not.
    setNotificationAddress:    (b) => call('POST', '/notifications/addresses', b),
    testEmailDelivery:         () => call('POST', '/notifications/test', {}),
    removeNotificationAddress: (b) => call('POST', '/notifications/addresses/remove', b),
    // Four acts, four calls. There is deliberately no decideAll: the endpoint
    // takes one finding, and a helper here that looped over them would be the
    // blanket acknowledge button rebuilt in the browser.
    requestOverride:   (b) => call('POST', '/overrides', b),
    requestConcessionOverride: (b) => call('POST', '/concessions/override', b),
    socialiseOverride: (b) => call('POST', '/overrides/socialise', b),
    decideOverride:    (b) => call('POST', '/overrides/decide', b),
    openOverrideGate:  (b) => call('POST', '/overrides/gate', b),
    nudgeRetention:(b) => call('POST', '/retention/nudge', b),
    // The governed library acts (D-5, NC-21/22/23). Each one act; the
    // authority is the schema's, and every refusal renders as its sentence.
    retireClause:      (b) => call('POST', '/library/retire', b),
    supersedeClause:   (b) => call('POST', '/library/supersede', b),
    publishRule:       (b) => call('POST', '/rules', b),
    retireRule:        (b) => call('POST', '/rules/retire', b),
    promoteConcession: (b) => call('POST', '/concessions/promote', b),
    // THE LIBRARY BUILDER (ADR-0010, 0102): ask a model for candidate wording.
    // It returns 200 whether or not a model was reached — an absence is an
    // outcome, and the body's `outcome` is the word to branch on, never the
    // presence of `text`. What comes back is a PROPOSAL: it lands as a draft
    // and an AI CANDIDATE ticket, and becomes language only when a named lawyer
    // verifies that ticket.
    draftLibraryCandidate: (b) => call('POST', '/library/draft', b),
    moveFloor:         (b) => call('POST', '/ladders/floor', b),
    publishLadder:     (b) => call('POST', '/ladders/publish', b),
    releaseHold:       (b) => call('POST', '/holds/release', b),
    // The playbook's two acts (0081), append-then-retire. Authoring is a
    // legal admin's act and retirement is the ONE change a recorded move
    // takes — every rule lives in the schema, and authored_by is bound from
    // the session, never sent from here.
    authorMove:        (b) => call('POST', '/moves', b),
    retireMove:        (b) => call('POST', '/moves/retire', b),
    destroyRetention:  (b) => call('POST', '/retention/destroy', b),
    redactAgreement:   (b) => call('POST', '/agreements/redact', b),
    purgeAgreement:    (b) => call('POST', '/agreements/purge', b),
    grantRecordsDelegate:  (b) => call('POST', '/records-delegates', b),
    revokeRecordsDelegate: (b) => call('POST', '/records-delegates/revoke', b),
    // The negotiation acts. EIGHT CALLS FOR EIGHT ACTS, and the two that look
    // like one act with a flag are two on purpose: opening from library
    // standard and opening from last term's executed positions are different
    // commercial decisions, and escalating is not "moving to the state
    // 'escalated'" — nobody should reach Legal by typing a string into a
    // field. There is no helper here that performs two of them together.
    openNegotiation:   (b) => call('POST', '/negotiations', b),
    openRenewal:       (b) => call('POST', '/negotiations/renew', b),
    recordRound:       (b) => call('POST', '/negotiations/rounds', b),
    openPosition:      (b) => call('POST', '/negotiations/positions', b),
    movePosition:      (b) => call('POST', '/negotiations/positions/move', b),
    escalatePosition:  (b) => call('POST', '/negotiations/positions/escalate', b),
    // The one act in this family whose body is a document. It names the deal
    // in the query string because its body IS the counterparty's file — there
    // is no record to carry the name.
    recordRedline: (agreementId, file) => send(
      '/negotiations/redline?agreement=' + encodeURIComponent(agreementId), file),
    // THE OTHER DOCUMENT, and it is a different act from the one above.
    // A redline is the counterparty marking up OUR paper. This is the
    // counterparty's OWN paper arriving whole (RP-05): it is parsed,
    // classified paragraph by paragraph against the category vocabulary, and
    // every unit it produces lands as a QUARANTINED review ticket. Not one
    // word of it is selectable by anything, and none of it can reach a
    // contract until a named lawyer approves wording for it.
    //
    // Served by the doorway since RP-05 and callable from no screen until
    // now, which meant the only paper the system could take in was a markup
    // of a draft we had already produced.
    ingestPaper: (agreementId, file) => send(
      '/paper/ingest?agreement=' + encodeURIComponent(agreementId), file),
    // The deal room's acts. Commenting, resolving a thread and entering the
    // room are each one recorded act; the analysis is the one call that may
    // ask a model, and it answers an honest absence when none was reachable.
    postDealComment:    (b) => call('POST', '/deal-room/comments', b),
    resolveDealComment: (b) => call('POST', '/deal-room/comments/resolve', b),
    reopenDealComment:  (b) => call('POST', '/deal-room/comments/reopen', b),
    enterDealRoom:      (b) => call('POST', '/deal-room/enter', b),

    // ── The working document (0130) ───────────────────────────────────────
    // Opening names the negotiation, and OPTIONALLY the round to seed from,
    // in the query string: the act carries no record of its own, and the
    // editor key it generates never comes back here — a browser holding it
    // would hold a capability before the thing that checks it exists.
    openWorkingDocument: (negotiationId, roundNo) => call('POST',
      `/deal-room/working-document/open?negotiation=${encodeURIComponent(negotiationId)}`
      + (roundNo == null ? '' : `&round=${encodeURIComponent(roundNo)}`)),
    // The one act here whose body is a document, the redline's shape: the
    // working document is named in the query string because the body IS the
    // file. Every save is kept — nothing is overwritten.
    saveWorkingDocument: (workingDocumentId, file) => send(
      '/deal-room/working-document/save?document='
      + encodeURIComponent(workingDocumentId), file),
    // Terminal, by the schema's own guard: work continues by opening a new
    // copy, never by reopening this one.
    closeWorkingDocument: (b) =>
      call('POST', '/deal-room/working-document/close', b),
    // Fetch OnlyOffice Document Server DocsAPI config with signed JWT
    onlyofficeConfig: (negotiationId) => call('GET',
      `/internal/onlyoffice/config?negotiation=${encodeURIComponent(negotiationId)}`),
    // Issue the active working copy as a new negotiation round
    issueWorkingDocument: (b) =>
      call('POST', '/deal-room/working-document/issue', b),
    analyseHighlight:   (b) => call('POST', '/deal-room/analyse', b),
    // The compliance check (0120): one changed paragraph read against the
    // declared concerns. The reply always carries the concerns themselves
    // and the honesty figures (concerns_declared / concerns_sent); the
    // model's opinion arrives labelled, or its absence with a reason.
    complianceCheck:        (b) => call('POST', '/compliance/check', b),
    // 0129: ask how much one echo pair's difference matters. The reply is
    // prose plus a four-word label; the echo stands without it.
    assessConflict:         (b) => call('POST', '/cross-contract/assess', b),
    declareComplianceConcern: (b) => call('POST', '/compliance/concerns', b),
    retireComplianceConcern:  (b) => call('POST', '/compliance/concerns/retire', b),

    // ── The four questions this system asks a machine ───────────────────
    //
    // Everything above this block either records what a person decided or
    // reads it back. These four ASK FOR AN OPINION, and each one has been
    // answerable by the doorway for weeks with no control anywhere on any
    // screen: the panels that show the answers were built, and the buttons
    // that ask the questions were not.
    //
    // WHAT MAKES THEM SAFE TO OFFER, and it is not this file:
    //
    //   · None of them can approve anything, mint anything, move a position
    //     or open a gate. The database has no policy that would let them.
    //   · Every answer is stored labelled as an estimate, with the model, its
    //     version and — when nothing was obtainable — the reason. An absence
    //     is an outcome here, never an error, so a screen must print the
    //     reason rather than an empty space.
    //   · The one of them that ESCALATES escalates to a person: a supplier
    //     paragraph matching no approved position becomes a review ticket in
    //     Legal's queue, quarantined, and a named lawyer decides it. That is
    //     the whole of the machine's authority — it can put something in
    //     front of somebody.
    //
    // THREE OF THEM NAME THE DEAL IN THE QUERY STRING rather than in a body,
    // because that is the shape the doorway already reads them in
    // (`server.QUERY_KEYS`), and encoded for the reason every other value in
    // this file is encoded.
    analyseRound: (agreementId) => call('POST',
      '/negotiations/analyse?agreement=' + encodeURIComponent(agreementId), {}),
    analyseSupplierUnits: (agreementId) => call('POST',
      '/negotiations/analyse/supplier?agreement=' + encodeURIComponent(agreementId), {}),
    assessConcessions: (agreementId) => call('POST',
      '/concessions/assess-risk?agreement=' + encodeURIComponent(agreementId), {}),
    // The fourth names a TICKET, and it is the only one of the four that can
    // be asked at all before a human has already decided something: it
    // compares the words a machine proposed against the words a lawyer
    // approved, so there is nothing to compare until the lawyer has ruled.
    // The doorway refuses it until then, in its own words.
    judgeSemanticDifference: (b) => call('POST', '/advisory/semantic-difference', b),

    // AI-6 (0104): ask for counter-language answering ONE changed paragraph.
    // The body says which paragraph, how serious it is, and the two things
    // 0029 fixes forever at creation — what the draft is for and what is known
    // to be unreliable about it. It may NOT say the wording, the model or any
    // identifier: a caller who could supply `text` could put words of their own
    // into a record that says a model wrote them.
    draftCounter: (b) => call('POST', '/negotiations/draft-counter', b),
    concede:           (b) => call('POST', '/concessions', b),
    approveConcession: (b) => call('POST', '/concessions/approve', b),

    // ── What became of a proposed concession (0010/0057, issue #156) ──────
    // 0010 built both tables, the gate and the actor binding; 0057 set the
    // write policies; analysis.py reads the settlement to answer whether a
    // concession is in force. Nothing wrote to either, so cw.concession_state's
    // 'approved' and 'withdrawn' branches were unreachable and every concession
    // was permanently 'proposed'.
    //
    // NEITHER BODY CARRIES AN ACTOR OR A COUNT. settled_by and withdrawn_by are
    // bound from the connection, and approvals_at_settlement is counted by the
    // gate at the moment of settling — a body that could send it would let
    // somebody record that more people had signed off than actually had.
    settleConcession:   (b) => call('POST', '/concessions/settle', b),
    withdrawConcession: (b) => call('POST', '/concessions/withdraw', b),
    shareAgreement:        (b) => call('POST', '/shares', b),
    revokeShare:           (b) => call('POST', '/shares/revoke', b),
    // Raising what you observed, and closing one. TWO ACTS, TWO CALLS, and
    // deliberately no acknowledgeAll: the endpoint takes one notice, and a
    // helper here that looped over them would be a blanket clear button
    // rebuilt in the browser — the override findings' argument exactly.
    raiseNotice:       (b) => call('POST', '/notices', b),
    acknowledgeNotice: (b) => call('POST', '/notices/acknowledge', b),
    createSupplier:      (b) => call('POST', '/suppliers', b),
    addSupplierAlias:    (b) => call('POST', '/suppliers/aliases', b),
    removeSupplierAlias: (b) => call('POST', '/suppliers/aliases/remove', b),
    linkSupplier:        (b) => call('POST', '/suppliers/link', b),
    unlinkSupplier:      (b) => call('POST', '/suppliers/unlink', b),
    // THE NAMED ACT (0114). It reads paragraphs out to the model provider and
    // records what came back AND what did not, on the chain. It is bounded and
    // re-runnable: the reply says how many paragraphs are still waiting, so a
    // screen says "run it again" rather than leaving somebody to guess whether
    // it finished.
    buildCrossContractIndex: (b) => call('POST', '/cross-contract/build', b),
    takeCheckpoint:() => call('POST', '/checkpoints', {}),
    // ENCODED TOO, and this one goes into the PATH rather than a query string,
    // which is the more consequential of the two places to leave raw. Both
    // call sites pass a literal today, so this changes nothing that runs —
    // it removes the exception, and an encoding rule with an exception in it
    // is the shape that produced the sibling defect above.
    runCheck: (which) => call('POST',
      `/health-checks/${encodeURIComponent(which)}`, {}),
  };
})();
