diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09d850f..98f6708 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,17 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build-and-test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false # pnpm version comes from the package.json "packageManager" field # (pnpm 10+), which is required to read `overrides` from # pnpm-workspace.yaml consistently with the committed lockfile. @@ -40,7 +46,7 @@ jobs: office: name: Office / Python ${{ matrix.python-version }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -51,7 +57,10 @@ jobs: env: PYTHONPATH: src steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c5ae120 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,169 @@ +# Inkspan Architecture + +Inkspan is a standalone authoring product and an embeddable module for CWL +applications. The architecture deliberately separates deterministic editor and +conversion behavior from host-owned transport, identity, tenancy, persistence, +and model policy so the same package can run independently or inside a modular +MSA composition. + +## Standalone product boundary + +Inkspan owns editor and deterministic conversion surfaces. + +The standalone package provides: + +- Markdown and HTML authoring through TipTap and ProseMirror; +- strict link and inline-image validation; +- SSR-safe React hydration; +- provider-neutral Yjs collaboration bindings; +- canonical versioned document envelopes and strict UTF-8 bytes; +- SHA-256 revision evidence and local revision-guarded restore; +- bounded single-flight autosave coordination and durable strong-validator + session helpers; +- email serialization and framework-independent base64 conversion; and +- a network-free Office renderer for deterministic DOCX, XLSX, and PPTX output. + +Hosts own transport, authorization, tenant isolation, persistence, credentials, migration, retention, and model-use policy. + +Inkspan therefore never opens a production collaboration connection, chooses a +tenant, stores a provider secret, creates a durable database transaction, decides +a retention schedule, or authorizes an AI operation. A standalone adopter can +provide those capabilities directly; a CWL host can provide them through shared +platform services. + +## Modular MSA composition + +The modular boundary is intentionally additive. Importing Inkspan does not +require naruon or contextual-orchestrator, while a CWL host can compose all +three without replacing Inkspan's deterministic local contracts. + +```mermaid +flowchart LR + Browser[Browser or desktop shell] + Panel[naruon compose / ui.panel host] + Inkspan[Inkspan editor module] + Evidence[Revision evidence and autosave] + Collab[Host-owned Yjs provider] + Store[Host persistence service] + Models[contextual-orchestrator] + Office[Office renderer] + Control[ContextualWisdomLab/.github control plane] + + Browser --> Panel + Panel --> Inkspan + Inkspan --> Evidence + Inkspan <--> Collab + Evidence --> Store + Panel --> Models + Inkspan --> Office + Control -. reusable CI, security, release policy .-> Inkspan + Control -. reusable CI, security, release policy .-> Panel + Control -. reusable CI, security, release policy .-> Models +``` + +### Component responsibilities + +| Component | Owns | Must not assume | +| --- | --- | --- | +| Inkspan | Editing, deterministic import/export, canonical envelopes, local revision evidence, local autosave ordering, accessible editor controls | User identity, tenant authority, durable commit success, provider credentials, retention, or model policy | +| ContextualWisdomLab/naruon | Product composition, route and panel lifecycle, authenticated host API calls, accessible conflict and recovery UX | That local Inkspan revision evidence is a server commit or authorization grant | +| ContextualWisdomLab/contextual-orchestrator | Provider-neutral model routing and host-approved model execution policy | Direct ownership of editor state, tenant persistence, or collaboration transport | +| ContextualWisdomLab/.github | Reusable CI, security, review, provenance, and release policy | Runtime authorization or tenant data access | +| Host persistence service | Atomic writes, server-selected strong validators, tenant isolation, migration, encryption, retention, audit storage | That browser-side checks replace server-side validation | +| Host collaboration service | Connection, room authorization, awareness policy, update persistence, provider lifecycle | That Inkspan may create or destroy the host provider | + +## Data ownership matrix + +| Data or evidence | Local Inkspan responsibility | Host responsibility | Shareability | +| --- | --- | --- | --- | +| Editor document | Validate and transform deterministically | Authorize access, persist, encrypt, migrate, retain | Private unless host policy explicitly permits sharing | +| Canonical envelope | Produce and validate exact schema/version bytes | Store, sign, classify, migrate, and apply retention | Usually private; contains the complete document | +| Local SHA-256 revision | Detect local equality and guard local restore | Never treat as authorization or durable commit evidence | Metadata only under tenant policy | +| Server-selected strong `ETag` | Validate syntax before use in a session | Select atomically and enforce `If-Match` in the write transaction | Tenant-confidential concurrency metadata | +| Yjs updates and awareness | Bind the supplied `Y.Doc` to the editor | Authorize rooms, transport, persist, redact, expire, and destroy providers | Host policy decides | +| Model prompt and output | Insert or restore validated results | Approve model use, credentials, redaction, routing, logging, and retention | Host policy decides | +| Release evidence | Expose deterministic tests and package contracts | Verify CI, provenance, approvals, and publication policy | Shareable only after secrets and tenant data are excluded | + +## Optimistic-concurrency sequence + +Inkspan coordinates local ordering, but the host service remains the only source +of durable success. The host returns a new server-selected strong `ETag` after +each accepted write. A local revision digest is never substituted for that +validator. + +```mermaid +sequenceDiagram + participant U as Author + participant I as Inkspan + participant H as naruon or standalone host + participant S as Persistence service + + U->>I: Edit document + I->>I: Capture immutable envelope and local revision + I->>H: Enqueue save evidence + H->>S: PUT document with If-Match: strong ETag + alt Current durable validator matches + S-->>H: 200/204 plus replacement strong ETag + H-->>I: saved with validated replacement validator + else Durable version changed + S-->>H: 412 Precondition Failed + H-->>I: conflict + I-->>U: Host renders accessible conflict, compare, merge, or fork flow + else Transport or ambiguous failure + S--xH: Failure without durable proof + H-->>I: blocked failure + I-->>U: Host requires explicit authenticated recovery + end +``` + +## SSR and panel lifecycle + +A server-rendered host may render Inkspan's deterministic shell, but the +interactive editor, browser-only provider, and `Y.Doc` must be created in a +client boundary. In a Next.js App Router integration, naruon should keep the +`'use client'` boundary as small as practical and pass only serializable, +non-secret configuration into the panel. Provider secrets remain server-side. + +The host owns provider creation and destruction. Inkspan may subscribe to the +supplied document and awareness state, but it must not create or destroy the +host provider. This allows one panel to mount and unmount without terminating a +provider shared by other product surfaces. + +## Security and privacy boundaries + +- Fail closed on malformed envelopes, unsafe links, external or active image + sources, invalid validators, unsupported versions, and ambiguous save results. +- Do not place full envelopes, conflict bodies, prompts, model output, provider + keys, access tokens, or tenant identifiers in ordinary logs or metrics. +- Treat local equality evidence separately from authenticated, shareable release + or audit evidence. +- Keep collaboration authorization, persistence authorization, and model-use + authorization independent even when the same user initiates all three. +- Pin reusable workflow sources immutably and require exact-head CI, security, + packaging, provenance, and independent review before release publication. +- Do not claim WCAG, OWASP ASVS, NIST, ISO, or protocol conformance from this + architecture document alone; verification belongs to the complete host and + deployed product. + +## Acquisition evidence boundary + +An acquisition reviewer should be able to verify the product without receiving +private tenant content or production credentials. Shareable evidence includes: + +- source, licenses, dependency locks, SBOMs, immutable workflow pins, and release + provenance; +- exact-head unit, integration, security, accessibility, packaging, and release + results; +- public API declarations, architecture and operator records, migration + contracts, and rollback procedures; and +- deterministic fixtures that contain no customer data. + +Local-only or restricted evidence includes production documents, conflict +bodies, Yjs updates, awareness metadata, provider credentials, authorization +claims, private model prompts and outputs, tenant-scoped validators, and +security findings that expose exploitable deployment detail. + +The authoritative naruon composition guide is +[`docs/naruon-compose-ui-panel.md`](docs/naruon-compose-ui-panel.md). Standards, +claim boundaries, and decision history are recorded in +[`docs/doctoring/naruon-modular-architecture.md`](docs/doctoring/naruon-modular-architecture.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b94ef8..f7db67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim ## [Unreleased] +### Documentation +- Added an authoritative standalone and modular MSA architecture contract with reviewable deployment, optimistic-concurrency, data-ownership, security, and acquisition-evidence diagrams and tables +- Added a beginner-readable naruon compose and ui.panel integration guide covering narrow client hydration, server-selected strong validators, accessible conflict handling, host-owned Yjs lifecycle, contextual-orchestrator boundaries, and local-versus-shareable evidence +- Added an opaque editing-context remount for the complete editor and autosave example, latest-generation asynchronous capture ordering, encoded document path segments, redacted recovery status, and lazy state-owned session identity to prevent cross-document state reuse +- Bounded the host save example with a fresh abort deadline, exposed authenticated conflict recovery through `session.resume(...)`, generated an instance-unique accessible heading relationship, and strengthened fenced-TSX ordering contracts +- Added stale-generation conflict recovery and operational save failure recovery through one reason-aware single-flight host workflow, so newer local edits cannot hide or duplicate recovery while retained work remains blocked +- Added exact-head read-only CI with fixed Ubuntu 24.04 runners, immutable action pins, explicit contributor-head checkout, disabled persisted Git credentials, and a documented merge-result compatibility boundary +- Added deterministic documentation contract tests and APA 7th doctoring grounded in RFC 9110, WCAG 2.2, NIST SP 800-204, NIST SP 800-204D, OWASP ASVS 5.0.0, React, current Next.js App Router guidance, and GitHub Actions primary documentation + ## [0.5.29] — 2026-08-05 ### Added diff --git a/docs/doctoring/exact-head-ci-evidence.md b/docs/doctoring/exact-head-ci-evidence.md new file mode 100644 index 0000000..64f1912 --- /dev/null +++ b/docs/doctoring/exact-head-ci-evidence.md @@ -0,0 +1,144 @@ +# Doctoring record: exact-head CI evidence + +- **Status:** Accepted +- **Decision date:** 2026-08-06 +- **Scope:** Repository-owned `CI` workflow only +- **Runtime change:** None; this changes verification source and runner controls + +## Problem + +GitHub's `pull_request` event exposes a synthetic pull-request merge ref as the +default checkout target. The prior Inkspan workflow used checkout defaults, so a +reported CI success described GitHub's temporary merge commit rather than the +immutable contributor head named in the pull request. The checkout action also +persisted its GitHub token into local Git configuration by default. + +A synthetic merge test can be useful compatibility evidence, but it is not exact +source identity evidence. Treating it as the pull-request head can make reviews, +coverage reports, packages, and release claims appear bound to a commit that the +contributor branch never contained. Persisting credentials is also unnecessary +for a read-only build and enlarges the impact of untrusted build or test code. + +## Decision + +The repository-owned `CI` workflow checks out: + +```yaml +ref: ${{ github.event.pull_request.head.sha || github.sha }} +persist-credentials: false +``` + +For a pull request, this selects the immutable contributor head. For a protected +branch push, it selects the event commit through `github.sha`. Both jobs use the +fixed `ubuntu-24.04` runner label and the immutable `actions/checkout` v7.0.1 +commit. Workflow permissions remain `contents: read`, and no write permission, +secret-bearing model call, release publication, approval, or branch update is +introduced. + +`FORCE_JAVASCRIPT_ACTIONS_TO_NODE24` is declared at workflow scope so the pinned +actions run under the repository's reviewed current JavaScript action runtime +rather than a deprecated implicit runtime. + +## Evidence boundary + +A successful run under this contract is exact-head CI evidence for the selected +source commit. It is not merge-result compatibility evidence. Branch protection, +a merge queue, or a separately reviewed integration test may still require a +fresh synthetic or protected-base integration result before merge. + +The workflow result also does not replace Security Scan, Semgrep, CodeQL, +independent review, release provenance, package publication, or deployment +acceptance. Each required surface must bind its own result to the intended source +identity. In particular, an organization-required workflow that still checks a +synthetic merge ref must not be described as exact-head evidence merely because +its check appears on the pull request. + +Local-versus-shareable evidence remains explicit. Checkout logs and exact source +SHAs are shareable release evidence. Repository tokens, runner credentials, +customer content, tenant identifiers, private callback output, and dependency +registry credentials are not. + +## Security consequences + +The contributor head is untrusted input. Disabling credential persistence keeps +the workflow token out of the checked-out repository's ordinary Git +configuration after checkout. The action still receives the job token long +enough to fetch the exact source, and the job retains only read permissions. + +This is defense in depth rather than a sandbox claim. Test processes can still +read files and use the network permitted by the hosted runner. Secrets must not +be attached to this untrusted pull-request job, and a future write-capable step +must use a separate trusted workflow boundary rather than broadening this one. + +Fixed runner labels reduce silent environment movement but do not make the hosted +image immutable. The exact image version remains visible in every job log. +Third-party actions are pinned to full commit identities, and the contract test +rejects mutable tags. + +## Test-first evidence + +Commit `14096f37f1aa47fb7f6661fea3b505193680aaf8` added the first workflow +contract before production changes. Commit +`e68781a8a2c96d5b103900e2622bf7de59925706` added this doctoring and changelog +contract before either record existed. + +Pull-request CI run `31066658465` produced the intended red result: all 550 other +JavaScript tests and both Office Python matrix jobs passed, while the three new +contracts failed for `ubuntu-latest`, the missing Node 24 action-runtime policy, +and the absent doctoring record. Its checkout log also showed +`persist-credentials: true` and the synthetic `refs/pull/64/merge` source. That +run is historical TDD evidence, not success evidence. + +Commit `bf348d6d2ad00589a990bc447f90548709533b4c` applied the workflow repair. +The final head must prove from its own checkout log that the exact contributor +head was selected and `persist-credentials: false` was effective before this +record can support merge readiness. + +## Rejected alternatives + +### Keep the default checkout ref and rename the claim + +Rejected because the product and acquisition contract requires exact-head +coverage, packaging, and review evidence. A merge-only result cannot substitute +for the reviewed source identity. + +### Use `pull_request_target` + +Rejected because that event's privileged base-repository context is unsafe for +executing untrusted pull-request code. This read-only workflow needs no privileged +context. + +### Keep persisted credentials for convenience + +Rejected because no workflow step commits, pushes, tags, opens a pull request, +or publishes a release. Retaining the credential has no product benefit. + +### Use mutable action tags or `ubuntu-latest` + +Rejected because both silently change the verification implementation. Full +action commits and a fixed runner family make changes reviewable and reproducible +enough for this hosted-runner boundary. + +## Rollback + +Rollback restores the prior workflow and removes this record and its contract. +That rollback must also remove every exact-head claim because the default +pull-request checkout returns to a synthetic merge source and persisted Git +credentials. It does not provide a safe emergency path for publication or +approval. + +No package version, database object, migration, runtime dependency, provider, +credential, scheduler, model call, or release is introduced. + +## APA 7 references + +GitHub, Inc. (n.d.). *Events that trigger workflows*. GitHub Docs. Retrieved +August 6, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows + +GitHub, Inc. (n.d.). *Workflow syntax for GitHub Actions*. GitHub Docs. +Retrieved August 6, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax + +GitHub, Inc. (n.d.). *actions/checkout*. GitHub. Retrieved August 6, 2026, from +https://github.com/actions/checkout diff --git a/docs/doctoring/naruon-modular-architecture.md b/docs/doctoring/naruon-modular-architecture.md new file mode 100644 index 0000000..4e07af6 --- /dev/null +++ b/docs/doctoring/naruon-modular-architecture.md @@ -0,0 +1,340 @@ +# Doctoring record: naruon modular architecture + +- **Status:** Accepted +- **Decision date:** 2026-08-05 +- **Scope:** Repository architecture, naruon composition, acquisition evidence, + and operator-facing integration guidance +- **Runtime change:** None + +## Problem + +Inkspan already had strong implementation-level boundaries for editing, +collaboration, deterministic conversion, revision evidence, and durable autosave, +but those boundaries were distributed across feature-specific documents. A +buyer, naruon integrator, or new operator could not review one authoritative +system view that explained: + +- how standalone use differs from modular CWL composition; +- which responsibilities remain with Inkspan and which belong to the host; +- how `ContextualWisdomLab/.github`, `ContextualWisdomLab/naruon`, and + `ContextualWisdomLab/contextual-orchestrator` fit together; +- where SSR, Yjs provider lifecycle, strong HTTP validators, accessibility, and + release evidence cross trust boundaries; +- how to prevent cross-document state reuse when a host changes the authorized + document, workspace, or tenant context in an existing client tree; and +- which evidence may be shared during support, procurement, or acquisition + diligence without disclosing tenant data. + +That gap increased integration error risk even though the underlying package +contracts were already provider-neutral. + +## Decision + +Add one authoritative root `ARCHITECTURE.md` and one beginner-readable naruon +`compose` / `ui.panel` integration guide. + +The architecture explicitly states: + +> Inkspan owns editor and deterministic conversion surfaces. +> Hosts own transport, authorization, tenant isolation, persistence, +> credentials, migration, retention, and model-use policy. + +The documentation uses reviewable Mermaid diagrams for the modular component +map and RFC 9110 optimistic-concurrency sequence. It defines local versus +shareable evidence, host-owned Yjs provider lifecycle, narrow client hydration, +server-selected strong validators, bounded host save callbacks, accessible +conflict recovery through `session.resume(...)`, instance-unique panel labels, +exact-head release evidence, and one opaque editing-context lifecycle boundary +that remounts the editor, autosave session, pending digest state, and status state +together. Document identifiers are encoded before transport and remain subject +to host-side authorization and route validation. + +## Architectural consequences + +### Positive + +- Standalone adopters receive a complete boundary without installing naruon or + contextual-orchestrator. +- CWL hosts can compose common infrastructure without moving editor-owned + validation or deterministic conversion into the host. +- Server-side secrets, tenant authorization, persistence, and model-use policy + remain outside the browser editor package. +- Integrators receive a concrete fail-closed autosave and conflict sequence with + a bounded request and explicit authenticated recovery handoff. +- A host-selected opaque editing-context key prevents uncontrolled editor state, + durable validators, and in-flight digest completion from crossing authorized + document boundaries. +- Multiple panels on one page retain separate accessible heading relationships. +- Acquisition reviewers can distinguish reproducible product evidence from + restricted customer or deployment evidence. +- The diagrams are version-controlled, text-reviewable, and render directly in + GitHub without an external design artifact. + +### Trade-offs + +- The guide is an architecture contract rather than a ready-made naruon + persistence adapter. Host implementations still need their own authenticated + APIs and database transactions. +- The editing-context key deliberately discards client-local editor and autosave + state on an authorized context transition. A host that permits drafts across + transitions must persist and reauthorize those drafts outside Inkspan before + issuing the new context. +- The example's ten-second deadline is illustrative. Each host must derive a + bounded timeout, retry, and idempotency policy from its operating environment. +- Mermaid rendering is useful for review but is not a substitute for accessible + prose; every diagram is accompanied by equivalent text and tables. +- Documentation tests can prove fenced example structure, required ordering, and + required statements remain present, but they do not prove a deployed host is + secure, accessible, reliable, or conformant. + +## Security boundary + +The decision follows the least-authority split recommended for modular systems: + +- the editor receives document state and non-secret presentation configuration; +- the host resolves identity, tenant membership, provider credentials, and + durable storage authority; +- the host issues a fresh opaque editing-context lifecycle value after each + authorized load or context transition; +- the host bounds every durable save callback with a fresh timeout or abort + signal and treats timeout or abort as ambiguous rather than successful; +- conflict recovery supplies a server-selected strong validator only after an + authenticated reload, merge, fork, discard, or equivalent confirmed decision; +- the persistence service selects and atomically enforces strong entity tags; +- the collaboration host owns room authorization, update persistence, and + provider lifecycle; +- contextual-orchestrator owns only host-approved provider-neutral model + execution; and +- the central `.github` repository owns reusable CI, security, review, + provenance, and release policy rather than runtime data authority. + +The editing-context value is not an authorization grant, tenant identifier, +durable validator, audit identifier, or credential. It must not be derived from +a document body or treated as server-side access evidence. Its sole purpose is to +provide a non-secret client lifecycle identity so React destroys the entire +stateful subtree when the authorized editing context changes. The host still +validates every document identifier and every write independently. + +The architecture does not claim compliance with OWASP ASVS 5.0.0, NIST SP +800-204, NIST SP 800-204D, WCAG 2.2, or RFC 9110 by documentation alone. Those +sources inform the boundary and verification plan. Conformance and assurance +must be assessed on the complete deployed host and its operating controls. + +## Accessibility boundary + +The naruon `ui.panel` guide requires keyboard-operable conflict actions, +labelled status and conflict regions, controlled focus movement, restrained +live-region announcements, and a unique React-generated heading relationship for +each panel instance. This supports WCAG 2.2-oriented integration, but the host +remains responsible for testing the complete page, all responsive states, and +third-party content before making a conformance claim. + +An editing-context remount also replaces focusable editor DOM. The host therefore +owns deterministic focus placement after the newly authorized panel is mounted +and must not leave focus on a removed node or announce private document content. + +## SSR and hydration boundary + +The React hydrateRoot API attaches an interactive tree to server-generated markup. +Next.js App Router separates Server and Client Components and uses `'use client'` +to declare the client module boundary. The integration therefore keeps Inkspan, +browser-only providers, event handlers, and `Y.Doc` creation in the narrow +client panel while authorization, credential access, and initial durable loading +remain server-side. + +React associates state with a component's position in the render tree and +supports an explicit `key` to reset a stateful subtree. The host-facing wrapper +therefore keys an inner client-session component with the opaque authorized +editing-context value. The autosave coordinator is retained in lazy React state +inside that keyed component. It is not created with `useMemo`, because React +documents `useMemo` as a performance optimization rather than a semantic +identity guarantee and may discard its cached value. The initializer has no +transport, timer, credential, persistence, or storage side effects; cleanup +closes the retained session when the keyed subtree is removed. + +The server-facing boundary receives only serializable data. A host-owned client +composition creates the conflict-recovery callback inside the client boundary +rather than attempting to serialize a function from a Server Component. + +This is an architectural recommendation, not a dependency on Next.js. A +traditional React SSR host may apply the same separation with its own server and +client entry points. + +## Optimistic-concurrency boundary + +RFC 9110 defines validators and conditional request semantics. Inkspan validates +strong entity-tag syntax and coordinates local single-flight ordering, but the +host persistence transaction remains authoritative. The host must compare the +`If-Match` value atomically with the durable representation and return a new +server-selected strong `ETag` after an accepted write. + +A timeout, disconnect, abort, or malformed response is ambiguous. The host does +not advance the durable validator or automatically retry unless separate +idempotency evidence establishes the prior outcome. After an authenticated +conflict decision, the client recovery boundary calls `session.resume(...)` so +the validated replacement tag is installed immediately before retained work +continues. + +A local Inkspan SHA-256 revision remains equality evidence for local deterministic +operations. It is not substituted for a durable HTTP validator, authorization +decision, signature, tenant identifier, or audit record. + +## Acquisition evidence boundary + +Shareable evidence is deliberately reproducible from source and non-customer +fixtures: exact-head CI, security results, package hashes, SBOMs, provenance, +licenses, public declarations, deterministic conversion fixtures, operator +records, migration contracts, and rollback procedures. + +Restricted evidence includes document envelopes, conflict bodies, Yjs updates, +awareness state, prompts, model outputs, provider credentials, tenant-scoped +validators, authorization claims, and deployment-specific exploitable findings. +Hashing, canonicalization, encryption, or successful CI does not automatically +make restricted evidence shareable. + +The editing-context key is intentionally non-secret, but it remains local product +state and should not be promoted into logs, analytics dimensions, support +artifacts, or acquisition evidence. Reproducible tests prove the lifecycle +contract without recording a tenant-derived value. + +## Verification + +`src/architectureDocumentation.test.ts` extracts the fenced TSX example and fails +unless: + +- the root architecture contains the standalone and modular ownership boundary; +- the CWL repositories and host responsibilities are named; +- both deployment and optimistic-concurrency Mermaid diagrams exist; +- the naruon guide includes client-boundary, strong-validator, accessible + conflict, provider-lifecycle, credential, and evidence contracts; +- the complete editor/autosave subtree is keyed by an opaque editing-context + value, uses lazy component state rather than `useMemo` for session identity, + and encodes the document identifier before transport; +- each panel creates an instance-unique heading ID and binds its own + `aria-labelledby` relationship; +- the host save callback supplies a fresh bounded abort signal; +- the latest-generation capture, digest, first guard, enqueue, and second guard + remain in the required order; +- the conflict path exposes a host recovery callback that invokes + `session.resume(...)` with the recovered strong validator; +- this doctoring record retains the authoritative standards references; and +- `CHANGELOG.md` records the unreleased buyer-visible documentation slice. + +The repository-wide TypeScript, 100% production statement/branch/function/line +coverage, package, Office, security, SAST, review, and exact-head branch +protection gates remain authoritative. + +## Rejected alternatives + +### Put naruon-specific behavior inside Inkspan + +Rejected because it would break standalone operation, make the editor own host +transport and identity concerns, and couple releases to one product shell. + +### Publish a complete persistence adapter in this slice + +Rejected because transport, authorization, tenant isolation, database schema, +migration, retention, and credential policy are host responsibilities. A sample +adapter could accidentally become an insecure de facto production contract. + +### Reuse one client panel across authorized document contexts + +Rejected because an uncontrolled editor, autosave validator, pending digest, and +status state can remain associated with the same React tree position after props +change. That creates a credible cross-document state reuse path in which content +from the previous document may be sent to the next document route. Keying the +whole inner session prevents partial reset and binds cleanup to one authorized +editing context. + +### Use `useMemo` as the autosave session identity boundary + +Rejected because React defines memoization as a performance optimization rather +than a semantic guarantee. A mutable coordinator with explicit cleanup belongs +in state owned by the keyed session subtree. The host changes the key, not a +memoization dependency, to replace the complete authorized editing context. + +### Leave durable save callbacks unbounded + +Rejected because an unresolved host callback retains the single-flight request +and prevents later enqueue, flush, and close operations from completing. The +host must provide a finite timeout or abort boundary and preserve ambiguous-write +semantics. + +### Reuse one static accessible heading ID + +Rejected because multiple panels could produce duplicate IDs and cause +`aria-labelledby` to reference the wrong heading. Each mounted panel creates its +own React-generated ID. + +### Display a conflict without exposing a recovery handoff + +Rejected because status text alone cannot install the authenticated replacement +validator or resume retained work. The host client boundary must receive a +bounded recovery callback tied to the exact autosave session. + +### Store provider credentials in panel props or environment-reading editor code + +Rejected because browser props are observable and package-level environment +resolution would violate the host-owned secret boundary. + +### Treat local SHA-256 evidence as the server validator + +Rejected because the server is responsible for representation selection and +atomic conditional writes. Local equality evidence cannot prove durable commit +success. + +### Use an external drawing-only artifact + +Rejected for the authoritative record because a binary or hosted-only diagram is +harder to diff, review, version, and validate in repository CI. Figma remains +appropriate for interaction design when visual fidelity materially improves a +future UI slice; the current architecture is better represented as text and +Mermaid. + +## Rollback + +Rollback removes `ARCHITECTURE.md`, `docs/naruon-compose-ui-panel.md`, this +record, and the associated documentation contract test, then removes the +unreleased changelog entry. No runtime, package export, dependency, database, +credential, workflow, or published version rollback is required. + +A host that has implemented the opaque editing-context boundary must not remove +that boundary merely because this documentation slice is rolled back. It should +retain or replace the protection with an independently verified equivalent that +prevents state and validator reuse across authorized document contexts. + +## APA 7 references + +Chandramouli, R. (2019). *Security strategies for microservices-based +application systems* (NIST Special Publication 800-204). National Institute of +Standards and Technology. https://doi.org/10.6028/NIST.SP.800-204 + +Chandramouli, R., Kautz, F., & Torres-Arias, S. (2024). *Strategies for the +integration of software supply chain security in DevSecOps CI/CD pipelines* +(NIST Special Publication 800-204D). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-204D + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110; +STD 97). RFC Editor. https://doi.org/10.17487/RFC9110 + +Meta Platforms, Inc. (n.d.). *Client React DOM APIs: hydrateRoot*. React. +Retrieved August 5, 2026, from https://react.dev/reference/react-dom/client + +Meta Platforms, Inc. (n.d.). *Preserving and resetting state*. React. Retrieved +August 5, 2026, from https://react.dev/learn/preserving-and-resetting-state + +Meta Platforms, Inc. (n.d.). *useMemo*. React. Retrieved August 5, 2026, from +https://react.dev/reference/react/useMemo + +Meta Platforms, Inc. (n.d.). *useState*. React. Retrieved August 5, 2026, from +https://react.dev/reference/react/useState + +Open Worldwide Application Security Project Foundation. (2025). *OWASP +Application Security Verification Standard 5.0.0*. +https://owasp.org/www-project-application-security-verification-standard/ + +Vercel. (2026, March 16). *Server and Client Components*. Next.js. +https://nextjs.org/docs/app/getting-started/server-and-client-components + +World Wide Web Consortium. (2024, December 12). *Web Content Accessibility +Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ diff --git a/docs/doctoring/stale-generation-conflict-recovery.md b/docs/doctoring/stale-generation-conflict-recovery.md new file mode 100644 index 0000000..773455e --- /dev/null +++ b/docs/doctoring/stale-generation-conflict-recovery.md @@ -0,0 +1,208 @@ +# Doctoring record: stale-generation conflict recovery + +- **Status:** Accepted +- **Decision date:** 2026-08-06 +- **Scope:** Naruon `compose` / `ui.panel` durable autosave example +- **Runtime change:** None; this record corrects the host integration contract + +## Problem + +The documented panel protected asynchronous document capture with a monotonically +increasing edit generation. That guard correctly prevented an older, slower +revision digest from being enqueued after a newer edit, but the same guard was +also applied immediately after the autosave queue settled. + +That ordering created a blocked-but-unreported conflict path: + +1. edit generation A is captured, enqueued, and becomes the active host save; +2. generation B arrives while A is active, increments the generation, and is + retained as pending work; +3. A receives `412 Precondition Failed`, so the single-flight queue enters its + blocked conflict state while B remains retained; +4. the old example observes that A is stale and returns before requesting host + recovery; and +5. B cannot start until recovery, while no accessible recovery workflow is + opened and the UI may continue to report an ordinary saving state. + +A related path existed for operational failures. A callback exception, abort, +timeout, or malformed result rejects the active `enqueue()` promise and blocks +the same queue with reason `failure`. The example displayed a generic action +message but did not actually invoke a host recovery workflow, so pending newer +work could remain blocked indefinitely. + +A generation guard is appropriate for superseded local presentation work. It is +not authority to discard a queue-wide blocking outcome whose recovery is needed +by newer retained work. A status message alone is not a recovery mechanism. + +## Decision + +Use two generation guards with separate responsibilities and one reason-aware +recovery boundary. + +The first guard remains between digest completion and enqueue. It rejects a stale +capture before that document can enter the durable queue. + +The invariant is: blocking outcomes before stale-generation status suppression. +A conflict therefore opens one host-owned recovery workflow even when the request +that first observed it belongs to an older generation. Only non-blocking saved, +unchanged, superseded, or closed presentation updates are suppressed when their +generation is stale. + +If `enqueue()` rejects, the example reads the document-free queue snapshot. When +that snapshot is blocked and supplies a stable `blockedReason`, the +host callback failure and a durable conflict both request recovery through the +same bounded interface. The host receives only `conflict` or `failure`; it does +not receive the document, validator, callback value, or private exception. + +The panel keeps one local `durableRecoveryPending` ref and issues one +single-flight recovery request for one blocked session. Multiple callers may +share the active queue outcome, so this ref prevents duplicate dialogs or +competing authenticated reloads without becoming persistence, authorization, or +a durable lock. + +For `conflict`, the host may compare, merge, fork, discard, or perform an +authenticated reload. For `failure`, the host must first determine whether an +ambiguous write committed and obtain the authoritative current representation +and server-selected strong `ETag`; it must not blindly retry the failed evidence. + +The recovery callback calls `session.resume(recoveredStrongEntityTag)`. Inkspan +validates the strong entity tag and installs it before retained work starts. A +malformed validator fails closed, preserves the previous durable base, and +returns a generic recovery status without exposing the supplied value or private +exception. + +Operational callback failures are handled from the document-free session +snapshot. If the queue is blocked, the host recovery action remains active even +when an older edit generation observed the failure. The ordering invariant is +therefore queue-wide rather than generation-local. + +## Ownership boundary + +Inkspan continues to own only deterministic document evidence, the local +single-flight queue, stable blocked-reason metadata, strong-tag syntax +validation, and durable-validator handoff. The host continues to own: + +- authentication, authorization, and tenant isolation; +- atomic `If-Match` enforcement and persistence; +- request deadlines, cancellation, idempotency, and retry policy; +- accessible compare, merge, fork, discard, verify, and reload workflows; +- determination of whether an ambiguous write committed; +- selection of the recovered server validator; and +- private transport and incident telemetry. + +The local pending flag and blocked reason are not tenant identifiers, audit +records, distributed locks, or proof that durable recovery succeeded. The host +callback returning `true` means only that the current local session accepted the +validated recovery transition. + +## Security and privacy consequences + +The repair prevents a newer keystroke from hiding a durable conflict that still +blocks newer work. It also prevents an operational save failure from leaving +retained work blocked behind a message that offers no callable recovery path. +Several shared callers cannot open parallel recovery workflows for the same +blocked session. + +Generic status text contains no document body, server validator, callback value, +tenant metadata, credential, or private exception. The panel never retries an +ambiguous write automatically. A recovered validator must come from an +authenticated durable reload or equivalent confirmed host decision. + +## Accessibility consequences + +A blocked queue remains represented by one accessible recovery surface until the +supplied recovery callback succeeds or the host deliberately abandons the +editing context. New local edits must not dismiss, duplicate, or obscure that +surface. The host remains responsible for focus movement, keyboard operation, +labelling, reason-appropriate actions, and restoration to the editor after +resolution. + +## Test-first evidence + +Commit `43a211b0818636016e2e80d9ceaaad5ab7af1fd7` added the original ordering +and single-flight conflict contract before the guide implemented it. +Pull-request workflow run `31065769175` produced the intended red result: the +new documentation test failed while 549 other JavaScript tests and both Office +Python package jobs passed. That workflow checked GitHub's synthetic pull-request +merge ref, so it is historical TDD evidence rather than exact-head acceptance +evidence. + +Commit `984a35b3dfb140b8f1099e0413f80fc4d1103e9b` extended the red contract to +require this doctoring record and `CHANGELOG.md` evidence. Commit +`bdfb75179f42cb10217803248685bc4e79578d05` then changed the fenced integration +example so a conflict is handled before the second generation guard and the host +recovery request is single-flight. + +Commit `f6cdcf4c5879c4c1661731590d9295fb61485205` added the reason-aware +operational-recovery contract before the guide implemented it. The contract +requires the public `DocumentAutosaveBlockedReason`, one shared recovery guard, +conflict recovery before stale status suppression, and a catch path that invokes +host recovery from a blocked snapshot rather than merely changing text. + +Commit `dd8edbe8e4b8953ed5ef91fe864c052879b79b07` then implemented the +reason-aware host boundary. The final integrated head must still pass +repository-wide TypeScript, 100% production statement/branch/function/line +coverage, package consumers, Office, security, SAST, review, and +branch-protection gates. Red runs and commits are historical TDD evidence and are +not merge evidence. + +## Rejected alternatives + +### Keep the second generation guard before conflict handling + +Rejected because a stale active request can be the request that transitions the +whole queue into a blocked state while newer work remains pending. + +### Display an operational error without invoking recovery + +Rejected because the queue remains blocked and newer retained work cannot start. +A visible message that has no associated host recovery workflow is not actionable +reliability behavior. + +### Resume automatically with the previous validator + +Rejected because the previous validator may have been rejected or may no longer +describe durable state. Automatic retry would violate authenticated conflict and +ambiguous-write boundaries. + +### Open one recovery workflow per enqueue caller + +Rejected because active or pending revisions can share outcomes. Parallel +recovery workflows can race to install different durable bases and present +inconsistent user decisions. + +### Bind recovery to the generation that observed the block + +Rejected because both conflict and failure block the queue, not only one visual +generation. Recovery must remain available to unblock retained newer work. + +### Expose the original callback error to select recovery behavior + +Rejected because transport exceptions can contain URLs, headers, tenant data, +provider details, or other private material. The stable document-free blocked +reason is sufficient for control flow. + +## Rollback + +Rollback restores the prior example and removes the expanded documentation +contract. Such a rollback also restores the known risks that a newer edit can +hide a queue-wide conflict and an operational failure can block retained work +without invoking recovery. A production host should not adopt that rollback +unless it already provides an independently verified equivalent recovery +coordinator. + +No package version, runtime dependency, database object, migration, credential, +network client, provider, scheduler, or release publication is introduced by +this documentation repair. + +## APA 7 references + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110; +STD 97). RFC Editor. https://doi.org/10.17487/RFC9110 + +Herlihy, M. P., & Wing, J. M. (1990). Linearizability: A correctness condition +for concurrent objects. *ACM Transactions on Programming Languages and Systems, +12*(3), 463–492. https://doi.org/10.1145/78969.78972 + +Meta Platforms, Inc. (n.d.). *useRef*. React. Retrieved August 6, 2026, from +https://react.dev/reference/react/useRef diff --git a/docs/naruon-compose-ui-panel.md b/docs/naruon-compose-ui-panel.md new file mode 100644 index 0000000..6cb069e --- /dev/null +++ b/docs/naruon-compose-ui-panel.md @@ -0,0 +1,447 @@ +# Naruon compose and ui.panel integration + +This guide shows how to embed Inkspan in a naruon composition without making +Inkspan depend on naruon. The same editor package remains usable in a standalone +React application, while naruon owns product routing, authenticated service +calls, tenant context, persistence, credentials, conflict UX, and model policy. + +## Integration goals + +A correct integration should: + +1. keep the interactive editor in a narrow browser boundary; +2. keep provider credentials and authorization decisions outside Inkspan; +3. use server-selected strong `ETag` values for durable optimistic concurrency; +4. let Inkspan coordinate only deterministic local editing and save ordering; +5. keep the host-created `Y.Doc` and collaboration provider lifecycle outside the + editor module; +6. expose an accessible conflict, operational recovery, and unsaved-state + experience; and +7. separate local evidence from shareable evidence used for operations or due + diligence. + +## Recommended composition + +Use a server component or equivalent host loader to authorize the document and +load its durable representation. Pass only serializable document data, a +server-selected strong `ETag`, an opaque non-secret editing-context lifecycle +identifier, and non-secret presentation options into one small host client +boundary. That host client boundary creates the durable-recovery callback; a +server component must not attempt to serialize a function prop. + +```tsx +// app/documents/[documentId]/inkspan-panel.tsx +'use client'; + +import { useEffect, useId, useRef, useState } from 'react'; +import { + CwlEditor, + type CwlEditorHandle, +} from '@contextualwisdomlab/cwl-editor'; +import { + createDocumentAutosaveSession, + isStrongHttpEntityTag, + type DocumentAutosaveBlockedReason, + type DocumentAutosaveSession, +} from '@contextualwisdomlab/cwl-editor/autosave'; +import '@contextualwisdomlab/cwl-editor/styles.css'; + +interface InkspanPanelProps { + readonly editingContextId: string; + readonly documentId: string; + readonly initialMarkdown: string; + readonly initialStrongEntityTag: string; + readonly requestDurableRecovery: ( + blockedReason: DocumentAutosaveBlockedReason, + resumeWithStrongEntityTag: (recoveredStrongEntityTag: string) => boolean, + ) => void; +} + +export function InkspanPanel(props: InkspanPanelProps) { + return ; +} + +function InkspanPanelSession({ + documentId, + initialMarkdown, + initialStrongEntityTag, + requestDurableRecovery, +}: InkspanPanelProps) { + const titleId = useId(); + const editorRef = useRef(null); + const editGeneration = useRef(0); + const durableRecoveryPending = useRef(false); + const [saveMessage, setSaveMessage] = useState('Document loaded.'); + const [session] = useState(() => + createDocumentAutosaveSession({ + initialStrongEntityTag, + async save(request) { + const encodedDocumentId = encodeURIComponent(documentId); + const response = await fetch(`/api/documents/${encodedDocumentId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'If-Match': request.ifMatchStrongEntityTag, + }, + body: JSON.stringify(request.evidence.envelope), + signal: AbortSignal.timeout(10_000), + }); + + if (response.status === 412) { + return { status: 'conflict' }; + } + if (!response.ok) { + throw new Error('Document save failed without durable proof'); + } + + const nextStrongEntityTag = response.headers.get('ETag'); + if (!isStrongHttpEntityTag(nextStrongEntityTag)) { + throw new Error('Document save omitted a valid strong ETag'); + } + return { + status: 'saved', + nextStrongEntityTag, + }; + }, + }), + ); + + useEffect( + () => () => { + editGeneration.current += 1; + void session.close(); + }, + [session], + ); + + function requestBlockedSessionRecovery( + blockedReason: DocumentAutosaveBlockedReason, + ): void { + if (durableRecoveryPending.current) return; + durableRecoveryPending.current = true; + setSaveMessage( + blockedReason === 'conflict' + ? 'Saving paused. Resolve the durable document conflict.' + : 'Saving paused. Verify the durable document state before retrying.', + ); + + try { + requestDurableRecovery(blockedReason, (recoveredStrongEntityTag) => { + try { + const resumed = session.resume(recoveredStrongEntityTag); + if (resumed) { + setSaveMessage( + 'Recovered changes resumed with a durable validator.', + ); + } + return resumed; + } catch { + if (session.getSnapshot().state === 'blocked') { + setSaveMessage( + 'Recovery requires a valid server-selected strong ETag.', + ); + } + return false; + } finally { + durableRecoveryPending.current = false; + } + }); + } catch { + durableRecoveryPending.current = false; + if (session.getSnapshot().state === 'blocked') { + setSaveMessage('Saving paused. Use the host recovery action.'); + } + } + } + + async function captureAndQueueLatestDocument(): Promise { + const capturedGeneration = ++editGeneration.current; + setSaveMessage('Saving changes.'); + + try { + const evidence = + await editorRef.current?.getDocumentEnvelopeRevisionEvidence(); + if ( + evidence === undefined || + evidence === null || + capturedGeneration !== editGeneration.current + ) { + return; + } + + const outcome = await session.enqueue(evidence); + if (outcome.status === 'conflict') { + requestBlockedSessionRecovery('conflict'); + return; + } + if (capturedGeneration !== editGeneration.current) { + return; + } + + if (outcome.status === 'closed') { + setSaveMessage('Saving is unavailable because this session closed.'); + } else { + setSaveMessage('All current changes are saved or queued.'); + } + } catch { + const snapshot = session.getSnapshot(); + if ( + snapshot.state === 'blocked' && snapshot.blockedReason !== null + ) { + requestBlockedSessionRecovery(snapshot.blockedReason); + } else if (capturedGeneration === editGeneration.current) { + setSaveMessage('Saving paused. Use the host recovery action.'); + } + } + } + + return ( +
+

Document editor

+ { + void captureAndQueueLatestDocument(); + }} + /> +

+ {saveMessage} +

+
+ ); +} +``` + +The host must issue a new opaque `editingContextId` for every authorized document load +and whenever the authorized workspace, tenant, or document context changes. The +value is a UI lifecycle key only: it is not an authorization grant, tenant +identifier, durable validator, or audit identifier, and it should not be logged. +Keying the complete client session prevents React from reusing an uncontrolled +editor, autosave validator, pending digest, or status state for a different +document. Keying only `CwlEditor` is insufficient because the autosave session +and asynchronous capture state must be replaced in the same lifecycle boundary. + +The session uses lazy component state rather than `useMemo`. The keyed child owns +that state for one authorized editing context, and changing the key discards the +whole session subtree. Memoization is an optimization and must not be treated as +the semantic identity or disposal boundary for a mutable autosave coordinator. +The session constructor performs no transport, timer, credential, or storage +side effect; the retained session is closed by the component cleanup. + +Each panel instance creates its own React `useId()` value, so multiple editors on +one page retain distinct heading relationships. The opaque editing-context key +controls lifecycle replacement; the generated heading ID controls only the +local accessible-name relationship and is not an authorization or audit value. + +The first generation guard prevents an older, slower asynchronous envelope +digest from being enqueued after a newer edit. The second guard suppresses only +stale non-blocking status updates after queue settlement. A blocking conflict is +handled before that second guard: if an older active save conflicts after a newer +edit has already retained pending work, the host must still receive the recovery +request that can unblock the latest work. A production host may debounce before +capture to reduce hashing frequency, but it must preserve both ordering rules. + +A host callback exception, abort, timeout, or malformed success response rejects +the active enqueue promise and blocks the same queue with reason `failure`. +The catch path therefore reads only document-free lifecycle metadata and invokes +the same host-owned recovery boundary with the exact stable blocked reason. It +does this even when the request that observed the failure belongs to an older +edit generation, preventing retained newer work from remaining blocked behind a +misleading saving state. + +Multiple callers can share one active queue outcome. The +`durableRecoveryPending` ref therefore permits only one in-flight host recovery +workflow for the blocked session, whether the reason is `conflict` or `failure`. +It does not authorize recovery and is not persisted or logged. The host should +keep one accessible recovery surface active until the supplied callback returns +`true`; a `false` result means the session was no longer blocked or the supplied +validator was not accepted. A malformed recovery validator fails closed without +replacing the durable base. + +The example applies a fresh ten-second `AbortSignal` to each host-owned save +request so one unresolved callback cannot retain the single-flight queue forever. +Ten seconds is illustrative rather than a universal service-level objective. The +host must select a bounded deadline from its own latency and reliability policy, +and an aborted or timed-out write remains ambiguous: do not claim success, +advance the validator, or retry automatically without idempotency evidence. + +The `requestDurableRecovery` function is created by the host client composition, +not passed across the server-component serialization boundary. For `conflict`, +it should open an accessible compare, merge, fork, discard, or authenticated +reload workflow. For `failure`, it must first determine whether the ambiguous +write committed, then obtain the authoritative current representation and +server-selected strong `ETag`; it must not blindly retry the rejected evidence. +Only after either workflow reaches a confirmed durable decision may it invoke +the supplied callback with that recovered validator. + +The callback delegates to `session.resume(...)`, which validates and installs the +new durable base immediately before retained work continues. The host may invoke +the same supplied callback again after a `false` result, but it must not open a +second competing recovery workflow while the first remains active. The stable +blocked reason is control metadata, not authorization or proof of persistence. + +Private callback exceptions, document bodies, response values, and validators +remain outside the generic status message. The example is intentionally +transport-neutral beyond ordinary host `fetch()`. A production naruon +composition should place authentication, tenant resolution, request deadlines, +retry budgets, idempotency, telemetry, and error translation inside the host API +layer rather than the editor module. + +## compose contract + +A naruon `compose` layer should treat Inkspan as one bounded capability module. +It may combine editor output with templates, workflows, contextual-orchestrator, +or other CWL services, but it must preserve these ownership rules: + +- Inkspan receives only the document state and non-secret behavior options needed + for editing. +- The composition root resolves authorization and tenant context before the + panel receives a document. +- The composition root issues a fresh opaque editing-context lifecycle value for + every authorized load and context transition. +- The host client composition creates durable recovery callbacks; server + components pass only serializable, non-secret data into that boundary. +- The composition root decides whether model use is allowed and which reviewed + contextual-orchestrator policy applies. +- Model output returns as untrusted content and enters Inkspan through validated + insertion or revision-guarded restore paths. +- Durable save success is established only by the host persistence transaction + and its replacement strong validator. +- The composition root owns shutdown and cancellation when a route, workspace, + or application session ends. + +Inkspan must not read provider credentials, model credentials, database +credentials, or host authorization tokens. It also must not infer tenant +identity from a document body, revision digest, collaboration room name, editing +context value, blocked reason, or server validator. + +## ui.panel contract + +A naruon `ui.panel` host should provide the surrounding product experience: + +- document title, owner, workspace, and classification labels; +- save, offline, reconnecting, conflict, operational recovery, and read-only + status; +- accessible conflict actions such as compare, merge, fork, discard, and retry; +- accessible operational-failure actions such as verify, reload, resume, or + abandon after determining the durable state; +- confirmation before destructive replacement; +- model-use disclosure and user controls required by host policy; +- navigation and focus restoration when the panel opens or closes; and +- a support-safe error reference that excludes the document body and credentials. + +Use `role="status"` or another appropriate live-region pattern for asynchronous +save state. Do not announce every keystroke. When the queue blocks, move focus to +a labelled recovery region or dialog and provide a deterministic path back to +the editor. Every panel heading and `aria-labelledby` target must be unique in the +rendered page. A newer keystroke must not dismiss, duplicate, or hide a durable +recovery workflow that still blocks retained work. + +## Durable autosave and conflict handling + +The initial validator and every successful replacement must be a server-selected +strong `ETag`. The host persistence service must atomically compare `If-Match` +inside the same transaction that writes the new document representation. + +Treat outcomes as follows: + +| Outcome | Host behavior | +| --- | --- | +| Saved with replacement strong validator | Install the returned validator before the next save begins | +| `412 Precondition Failed` | Pause automatic progression and show exactly one accessible conflict workflow, even when a newer local edit already exists | +| Timeout, disconnect, abort, callback exception, or malformed response | Treat as ambiguous; do not claim saved, advance the validator, or retry automatically; show exactly one operational recovery workflow | +| Authenticated recovery load | Supply the newly confirmed server validator and original blocked reason through the recovery callback so `session.resume(...)` installs the validator before retained work continues | +| Route or panel shutdown | Stop new work, let any active transport settle according to host policy, then discard private in-memory evidence | + +A local Inkspan SHA-256 revision is equality evidence for deterministic local +operations. It is not a durable server validator and must never replace the +host's `ETag`. + +## Collaboration lifecycle + +For real-time editing, create the `Y.Doc`, provider, room authorization, and +awareness policy in the host composition. Inkspan may bind the supplied document +to the editor, but it must not create or destroy the host provider. + +This matters when one provider is shared by multiple panels, presence surfaces, +or background synchronization tasks. Unmounting an editor panel must not +silently terminate collaboration used elsewhere. The host should explicitly +destroy the provider only when the owning workspace or application lifecycle +ends. + +## contextual-orchestrator integration + +The host may call `ContextualWisdomLab/contextual-orchestrator` for insertion, +rewrite, review, or structured document generation. Keep the integration +provider-neutral: + +1. capture one immutable Inkspan envelope and local revision; +2. let the host authorize and dispatch the model operation; +3. validate the returned content through Inkspan's ordinary safe-content path; +4. apply a delayed result only with revision-guarded restore or an explicit + compare/merge/fork decision; and +5. store only host-approved audit metadata, never private intermediate reasoning. + +The editor package does not select reasoning effort, models, credentials, prompt +retention, or provider regions. Those decisions remain with the host and +contextual-orchestrator policy. + +## Local evidence and shareable evidence + +**Local evidence** may contain full envelopes, conflict bodies, Yjs updates, +awareness state, prompts, model output, tenant identifiers, server validators, +or deployment-specific security findings. Keep it within the authorized product +boundary and retention policy. + +**Shareable evidence** for support, release acceptance, procurement, or +acquisition review should be deliberately produced from non-customer fixtures. +Examples include exact-head CI results, package hashes, SBOMs, provenance, +license inventories, deterministic conversion fixtures, accessibility test +results, public API declarations, and redacted operator runbooks. + +Never promote local evidence to shareable evidence merely because it is hashed, +canonicalized, encrypted, or attached to a successful CI run. + +## Failure checklist + +Before enabling the panel in production, verify that: + +- the server rejects unauthorized document IDs before returning content; +- the host issues a fresh opaque editing-context lifecycle value for every + authorized document load and context transition; +- the complete editor and autosave session remount together when that lifecycle + value changes; +- document path segments are encoded before transport and revalidated by the + authorized server route; +- every durable write uses an authenticated atomic `If-Match` transaction; +- missing, weak, malformed, or stale validators fail closed; +- request timeouts and cancellation are host-owned and bounded; +- aborts and timeouts remain ambiguous rather than being reported as saved; +- blocking conflict outcomes are processed before stale-generation status + suppression so a newer edit cannot hide the required recovery workflow; +- a blocked operational save failure also invokes the host recovery workflow, + including when an older request first observes the failure; +- exactly one durable recovery workflow is requested for one blocked session; +- the stable `conflict` or `failure` reason is passed without private error data; +- authenticated recovery installs its confirmed strong validator through + `session.resume(...)` before retained work continues; +- operational recovery verifies the durable state instead of automatically + retrying an ambiguous write; +- every panel instance has a unique accessible heading relationship; +- recovery UI is keyboard-operable and announced without exposing document text + in generic telemetry; +- provider and model credentials never enter client props, document envelopes, + error messages, or collaboration awareness state; +- the host provider survives panel remounts when it is shared; +- release evidence excludes tenant data and is bound to the exact package and + source head; and +- rollback restores a previously verified package rather than bypassing + validation, security, or review gates. + +See [`../ARCHITECTURE.md`](../ARCHITECTURE.md) for the system diagrams, +[`doctoring/naruon-modular-architecture.md`](doctoring/naruon-modular-architecture.md) +for the architecture decision, and +[`doctoring/stale-generation-conflict-recovery.md`](doctoring/stale-generation-conflict-recovery.md) +for the concurrency repair evidence. diff --git a/src/architectureDocumentation.test.ts b/src/architectureDocumentation.test.ts new file mode 100644 index 0000000..6bd0b2a --- /dev/null +++ b/src/architectureDocumentation.test.ts @@ -0,0 +1,246 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryFile = (path: string): string => + readFileSync(resolve(process.cwd(), path), 'utf8'); + +const fencedCodeBlock = (markdown: string, language: string): string => { + const opening = `\`\`\`${language}\n`; + const start = markdown.indexOf(opening); + expect(start).toBeGreaterThanOrEqual(0); + const bodyStart = start + opening.length; + const end = markdown.indexOf('\n```', bodyStart); + expect(end).toBeGreaterThan(bodyStart); + return markdown.slice(bodyStart, end); +}; + +const markerPosition = ( + text: string, + marker: string, + startAt = 0, +): number => { + const position = text.indexOf(marker, startAt); + expect(position).toBeGreaterThanOrEqual(startAt); + return position; +}; + +describe('acquisition-ready modular architecture documentation', () => { + it('defines one authoritative standalone and modular ownership boundary', () => { + const architecture = repositoryFile('ARCHITECTURE.md'); + + expect(architecture).toContain('# Inkspan Architecture'); + expect(architecture).toContain('## Standalone product boundary'); + expect(architecture).toContain('## Modular MSA composition'); + expect(architecture).toContain('## Data ownership matrix'); + expect(architecture).toContain('## Acquisition evidence boundary'); + expect(architecture).toContain('ContextualWisdomLab/.github'); + expect(architecture).toContain('ContextualWisdomLab/naruon'); + expect(architecture).toContain( + 'ContextualWisdomLab/contextual-orchestrator', + ); + expect(architecture).toContain( + 'Inkspan owns editor and deterministic conversion surfaces.', + ); + expect(architecture).toContain( + 'Hosts own transport, authorization, tenant isolation, persistence, credentials, migration, retention, and model-use policy.', + ); + }); + + it('renders reviewable deployment and optimistic-concurrency diagrams', () => { + const architecture = repositoryFile('ARCHITECTURE.md'); + + expect(architecture).toContain('```mermaid\nflowchart LR'); + expect(architecture).toContain('```mermaid\nsequenceDiagram'); + expect(architecture).toContain('If-Match'); + expect(architecture).toContain('412 Precondition Failed'); + expect(architecture).toContain('strong ETag'); + expect(architecture).toContain('Y.Doc'); + expect(architecture).toContain('Office renderer'); + }); + + it('provides a naruon compose and ui.panel integration contract', () => { + const integration = repositoryFile('docs/naruon-compose-ui-panel.md'); + + expect(integration).toContain('# Naruon compose and ui.panel integration'); + expect(integration).toContain("'use client'"); + expect(integration).toContain('compose'); + expect(integration).toContain('ui.panel'); + expect(integration).toContain('server-selected strong `ETag`'); + expect(integration).toContain('accessible conflict'); + expect(integration).toContain('local evidence'); + expect(integration).toContain('shareable evidence'); + expect(integration).toContain('must not read provider credentials'); + expect(integration).toContain('must not create or destroy the host provider'); + }); + + it('validates fenced autosave structure, generation ordering, and durable recovery', () => { + const integration = repositoryFile('docs/naruon-compose-ui-panel.md'); + const example = fencedCodeBlock(integration, 'tsx'); + + expect(example).toContain('initialStrongEntityTag,'); + expect(example).toContain("'If-Match': request.ifMatchStrongEntityTag"); + expect(example).toContain('signal: AbortSignal.timeout(10_000),'); + expect(example).toContain('isStrongHttpEntityTag(nextStrongEntityTag)'); + expect(example).toContain('nextStrongEntityTag,'); + expect(example).not.toContain('loadedStrongEntityTag:'); + expect(example).not.toContain('request.ifMatch,'); + expect(example).not.toContain('strongEntityTag: nextStrongEntityTag'); + expect(example).toContain('const editGeneration = useRef(0);'); + expect(example).toContain('type DocumentAutosaveBlockedReason,'); + expect(example).toContain('const durableRecoveryPending = useRef(false);'); + expect(example).toContain( + 'readonly requestDurableRecovery: (\n blockedReason: DocumentAutosaveBlockedReason,\n resumeWithStrongEntityTag: (recoveredStrongEntityTag: string) => boolean,\n ) => void;', + ); + expect(example).toContain( + 'function requestBlockedSessionRecovery(\n blockedReason: DocumentAutosaveBlockedReason,\n ): void {', + ); + expect(example).toContain('if (durableRecoveryPending.current) return;'); + expect(example).toContain('durableRecoveryPending.current = true;'); + expect(example).toContain( + 'requestDurableRecovery(blockedReason, (recoveredStrongEntityTag) => {', + ); + expect(example).toContain( + 'const resumed = session.resume(recoveredStrongEntityTag);', + ); + expect(example).not.toContain('requestConflictRecovery'); + expect(example).not.toContain('conflictRecoveryPending'); + + const resumeDeclaration = markerPosition( + example, + 'const resumed = session.resume(recoveredStrongEntityTag);', + ); + const successfulResumeBranch = markerPosition( + example, + 'if (resumed) {', + resumeDeclaration, + ); + const pendingRecoveryRelease = markerPosition( + example, + 'durableRecoveryPending.current = false;', + successfulResumeBranch, + ); + const resumeReturn = markerPosition( + example, + 'return resumed;', + successfulResumeBranch, + ); + expect(pendingRecoveryRelease).toBeLessThan(resumeReturn); + + const capture = markerPosition( + example, + 'const capturedGeneration = ++editGeneration.current;', + ); + const digest = markerPosition( + example, + 'await editorRef.current?.getDocumentEnvelopeRevisionEvidence();', + capture, + ); + const firstGenerationGuard = markerPosition( + example, + 'capturedGeneration !== editGeneration.current', + digest, + ); + const enqueue = markerPosition( + example, + 'const outcome = await session.enqueue(evidence);', + firstGenerationGuard, + ); + const conflictBranch = markerPosition( + example, + "if (outcome.status === 'conflict') {", + enqueue, + ); + markerPosition( + example, + "requestBlockedSessionRecovery('conflict');", + conflictBranch, + ); + const secondGenerationGuard = markerPosition( + example, + 'capturedGeneration !== editGeneration.current', + conflictBranch, + ); + const catchBlock = markerPosition(example, '} catch {', secondGenerationGuard); + const blockedSnapshot = markerPosition( + example, + "snapshot.state === 'blocked' && snapshot.blockedReason !== null", + catchBlock, + ); + markerPosition( + example, + 'requestBlockedSessionRecovery(snapshot.blockedReason);', + blockedSnapshot, + ); + + expect(example).toContain('void captureAndQueueLatestDocument();'); + expect(example).toContain('void session.close();'); + }); + + it('uses instance-unique labels and remounts the complete authorized client session', () => { + const integration = repositoryFile('docs/naruon-compose-ui-panel.md'); + const example = fencedCodeBlock(integration, 'tsx'); + const doctoring = repositoryFile( + 'docs/doctoring/naruon-modular-architecture.md', + ); + const changelog = repositoryFile('CHANGELOG.md'); + + expect(example).toContain('readonly editingContextId: string;'); + expect(example).toContain('function InkspanPanelSession('); + expect(example).toContain(''); + expect(example).toContain('

Document editor

'); + expect(example).not.toContain('id="document-editor-title"'); + expect(example).toContain( + 'const [session] = useState', + ); + expect(example).not.toContain('useMemo'); + expect(integration).toContain( + 'must issue a new opaque `editingContextId` for every authorized document load', + ); + expect(doctoring).toContain('cross-document state reuse'); + expect(changelog).toContain('opaque editing-context remount'); + }); + + it('records authoritative standards and the unreleased product change', () => { + const doctoring = repositoryFile( + 'docs/doctoring/naruon-modular-architecture.md', + ); + const conflictDoctoring = repositoryFile( + 'docs/doctoring/stale-generation-conflict-recovery.md', + ); + const changelog = repositoryFile('CHANGELOG.md'); + + expect(doctoring).toContain('APA 7 references'); + expect(doctoring).toContain('RFC 9110'); + expect(doctoring).toContain('WCAG 2.2'); + expect(doctoring).toContain('NIST SP 800-204'); + expect(doctoring).toContain('NIST SP 800-204D'); + expect(doctoring).toContain('OWASP ASVS 5.0.0'); + expect(doctoring).toContain('React hydrateRoot'); + expect(doctoring).toContain('Next.js App Router'); + expect(conflictDoctoring).toContain( + '# Doctoring record: stale-generation conflict recovery', + ); + expect(conflictDoctoring).toContain('single-flight recovery request'); + expect(conflictDoctoring).toContain( + 'blocking outcomes before stale-generation status suppression', + ); + expect(conflictDoctoring).toContain( + 'host callback failure and a durable conflict both request recovery', + ); + expect(changelog).toContain( + 'authoritative standalone and modular MSA architecture contract', + ); + expect(changelog).toContain('naruon compose and ui.panel integration'); + expect(changelog).toContain('stale-generation conflict recovery'); + expect(changelog).toContain('operational save failure'); + }); +}); diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts new file mode 100644 index 0000000..8a44b57 --- /dev/null +++ b/src/workflowExactHead.test.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** Read one authoritative repository file as UTF-8 text. */ +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +const workflow = repositoryFile('.github/workflows/ci.yml'); + +const CHECKOUT_PIN = + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1'; + +describe('exact-head CI workflow contract', () => { + it('uses a fixed runner and checks out the immutable current PR head', () => { + expect(workflow).not.toContain('ubuntu-latest'); + expect(workflow.match(/runs-on: ubuntu-24\.04/g)).toHaveLength(2); + expect(workflow.match(new RegExp(CHECKOUT_PIN, 'g'))).toHaveLength(2); + expect( + workflow.match( + /ref: \$\{\{ github\.event\.pull_request\.head\.sha \|\| github\.sha \}\}/g, + ), + ).toHaveLength(2); + expect(workflow.match(/persist-credentials: false/g)).toHaveLength(2); + }); + + it('keeps the workflow read-only and hash-pins every third-party action', () => { + expect(workflow).toContain('permissions:\n contents: read'); + expect(workflow).not.toContain('contents: write'); + expect(workflow).toContain( + 'env:\n FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true', + ); + + const usesLines = workflow + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('- uses:')); + expect(usesLines.length).toBeGreaterThan(0); + for (const line of usesLines) { + expect(line).toMatch(/@[0-9a-f]{40}(?:\s+#\s+v[^\s]+)?$/u); + } + }); + + it('records the evidence boundary and unreleased hardening', () => { + const doctoring = repositoryFile( + 'docs/doctoring/exact-head-ci-evidence.md', + ); + const changelog = repositoryFile('CHANGELOG.md'); + + expect(doctoring).toContain( + '# Doctoring record: exact-head CI evidence', + ); + expect(doctoring).toContain('synthetic pull-request merge ref'); + expect(doctoring).toContain('immutable contributor head'); + expect(doctoring).toContain('persist-credentials: false'); + expect(doctoring).toContain('not merge-result compatibility evidence'); + expect(changelog).toContain('exact-head read-only CI'); + }); +});