The Change Request for Phase 63. It draws against scoping note v0.2 (commit a2b6b78), which settled three design decisions and named one operational dependency after the Step 0 inspection grounded the scope. The CR fills in the execution layer: step-by-step instructions for Claude Code, acceptance gates that define what "done" means for each piece, test plans that verify the gates hold.
The CR is also the first artifact in the project to use a visual-classification system. Eight categories of element — verified facts, settled decisions, execution steps, acceptance gates, tests, operator-decision points, future-work markers, and open questions — each carry distinct visual treatment so the reader can tell at a glance what kind of thing each element is before reading its content. The classification key follows immediately below.
This is v0.1 of the CR. The cross-check cycle that standard methodology applies will find drift, find unclear specification, find places where the visual classification stretched in ways that need resolution. Those findings produce v0.2. The CR is not expected to be perfect on first draft; it is expected to surface its own boundaries.
Each element in this CR is one of eight categories. The visual treatment names the category; the content names the specific element. Reading the CR effectively means engaging with each category appropriately — verified facts don't need re-verification, settled decisions are not up for debate at execution time, operator-decision points must be surfaced rather than worked around.
The Step 0 inspection (findings at phases/phase-63-editor-refinement/loomworks-phase-63-step-0-findings-v0_1.html) verified eleven properties of the substrate before this CR opened. The verified facts that the CR depends on are restated here in their settled form. CC does not need to re-verify these at execution time; they are the substrate ground.
The audit.foray_events table accepts new event_type values without schema migration. The column is VARCHAR(64), no CHECK constraint, no enum type. "editor_commit" is 13 characters, well within bounds. Migration docstring explicitly anticipates future event types. Source: migrations/versions/0068_audit_foray_events.py:7-12,50.
The existing writer helper write_setting_change_event(...) at src/loomworks/audit/events.py:52-94 establishes the pattern for adding new narrative-event writers. Signature: keyword-only arguments, takes db: AsyncSession, constructs and adds an AuditForayEventRow, awaits db.flush() without its own db.begin(), returns tx_id: UUID. _json_safe(...) helper at lines 34-49 coerces payload values that are not JSON-encodable to str(value).
Both existing call sites (orchestration/tune_setting.py:83-99, api/routers/me_settings.py:116-131) wrap the writer in try/except SQLAlchemyError and swallow on failure with logger.exception(...). The discipline is uniform: the substrate write is authoritative; the audit row is best-effort. Comments at both sites name this explicitly.
FastAPI routers in src/loomworks/api/routers/. Pydantic v2 response models. Auth dependencies via Depends(...) from src/loomworks/api/deps.py: get_current_person (cookie path), get_current_contributor (cookie OR Bearer), get_committing_contributor (human + commit authority). Cursor-pagination convention established in dashboard.py (lines 61-76) and mirrored in admin_grants.py:222-300. Opaque base64-URL JSON cursor of shape {"ts": "<iso8601>", "id": "<uuid>"}.
Engine CORS is configured at src/loomworks/api/app.py:386-419 via CORSMiddleware. Origins from comma-separated LOOMWORKS_CORS_ORIGINS env var. Current allow_headers=["Content-Type"] — no custom auth header allowed. Methods include POST/PUT/PATCH/OPTIONS. allow_credentials=True. Dev/test envs auto-include localhost:3000/3001.
The substrate function get_person_by_email(...) exists at src/loomworks/persons/registry.py:108-129. Returns first match by created_at when multiple persons share an email (email is NOT unique at the database layer; docstring lines 113-118 names this explicitly). No HTTP endpoint exposes this function today — Phase 63 Piece 3 builds that endpoint.
No HTTP routes read from audit.foray_events or credit.foray_action_flows today. audit.foray_events has no internal selects in business logic — the table is write-only post-migration. Phase 63 Piece 3 builds the read endpoint from scratch. The four indexes on the table (tx_id; actor_person_id+ts DESC; event_type+ts DESC; engagement_id+ts DESC) pre-imagine the query shapes a read endpoint would need.
The current version-bump operation at functions/api/save.ts:104-181 writes body.content verbatim to newPath and currentContent.content verbatim to archivePath. No content transformation happens. The natural attachment point for a version-string scanner is between line 152 (after currentContent is fetched) and line 161 (before the multi-file commit).
After a successful version-bump save, tools/static/editor.js (function saveDocument at lines 158-197) shows a success status but does not redirect to the new path. The breadcrumb (rendered by static site build, not the editor JS) remains pointed at the predecessor until Cloudflare Pages rebuilds. The natural attachment point for an auto-reopen behavior is after line 184 (success setStatus) and before line 187 (button-disable block).
Step 0 found six pattern families across the repository's HTML documents. Patterns A (<title>), B (doc-meta / version-line), and C (footer paragraph) are canonical self-references — almost always safe to auto-substitute. Patterns D (transition prose), E (arrow notation and underscore form in code), and F (cross-document references) are intentionally polysemic — auto-substitution would produce wrong output. This is load-bearing for Piece 1's scanner design (see Decision A.4).
The scoping note v0.2 settled four design positions. Restated here in their CR-final form so they are uniformly visible in the execution document.
A long-lived service token, generated once, is stored in Cloudflare Pages secrets (named LOOMWORKS_ENGINE_SERVICE_TOKEN) and in the engine's environment (named identically). The Pages Function sends it as Authorization: Bearer <token>. The engine accepts it on Phase-63-introduced endpoints via simple equality check against the configured value.
Engine CORS widens to include Authorization in allow_headers. https://record.dunin7.com is added to LOOMWORKS_CORS_ORIGINS.
This is explicitly a single-Operator simplification. A future phase opening multi-Operator or non-DUNIN7 deployments will replace this with per-Operator OAuth-style flows or JWT propagation. The replacement path is named in Future-A.1 below.
Replacement of shared service token with Cloudflare Access JWT verification at the engine. The Pages Function would forward the Access JWT; the engine would verify its signature and trust the sub claim as a stable per-Access-user identifier. Requires JWT-verification work currently deferred at functions/api/_shared.ts:102-112, and the new mapping table from Future-A.2.
A new engine endpoint resolves an email address to a person UUID. Calls the existing get_person_by_email substrate function (F0.6). Accepts the service token from A.1. Returns 404 if no match; returns first-match UUID otherwise (per substrate semantics).
The Pages Function calls this endpoint once per editor session (when the operator opens any document for editing) and caches the result client-side in editor.js for the session duration.
Phase 63 accepts the engine's first-match-by-created_at semantics because record.dunin7.com has a single Operator (Marvin Percival as DUNIN7 Operator) — first-match is the right person almost by definition. Future multi-Operator work will need stricter identity-resolution; the path is named in Future-A.2.
Cloudflare Access JWT sub-claim-based identity with a new mapping table from Access-sub to engine-person-id. Stable per-Access-user, no email-uniqueness concerns. Requires a signup-time flow to populate the mapping. Deferred to its own arc when multi-Operator scope becomes load-bearing.
The Phase 63 audit viewer reads from audit.foray_events only. Value flows (credit.foray_action_flows) and memory events (memory_events content hashes) are out of scope for the Phase 63 viewer. Cross-substrate unification is named in Future-A.3.
Reasoning: each substrate has distinct schema and semantics; unification has design questions (consolidated event schema, merged time-ordering, performance of three-table queries) deserving their own scoping. Phase 63 ships the narrower coherent surface.
Cross-substrate FORAY consolidation. The three FORAY substrates (value flows, narrative events, memory events) presented in unified time-ordering with a consolidated event schema. Design work pending; not absorbed into Phase 63.
The Piece 1 version-string scanner does not auto-substitute. Each pattern match is surfaced to the operator with surrounding context (3-5 lines); the operator approves or declines each occurrence individually. Approved changes are applied atomically with the version-bump commit; declined occurrences remain unchanged.
This is load-bearing, not a design preference. Patterns D, E, and F (F0.10) are intentionally polysemic — auto-substitution would produce wrong output. Specifically: backward references like "we considered X in v0.1" must stay v0.1; cross-document references to other files at specific versions must not change when the current document is bumped. The propose-and-approve flow is the mechanism by which polysemic patterns are correctly handled. The scanner must surface enough context that the operator can judge correctly.
Phase 63 follows the existing FORAY narrative-event discipline (F0.3): the editor commit is authoritative; the audit row is best-effort. Failed audit writes log via logger.exception(...) and continue.
The reversed discipline — "the editor commit fails unless the audit row lands" — would require a transactional coupling between record.dunin7.com (Cloudflare Pages) and the loomworks-engine database that does not exist today. The substrate supports the stronger discipline; the cost is real (a more elaborate two-phase commit or a compensating-action mechanism). Phase 63 chooses the lenient discipline matching existing FORAY callers. A future phase can strengthen this if audit-completeness becomes load-bearing.
Phase 63 does not absorb engine deployment work into its execution. Piece 3 (the audit viewer) requires the loomworks-engine to be reachable at a production host with appropriate CORS configuration. The first execution step of Piece 3 verifies engine reachability; if the engine is not reachable, execution halts at Decision-Required-DR1 (defined below).
The current production host candidates are api.loomworks.doneinseven.com (named in Phase 51 implementation notes) and api.loomworks.dunin7.com via M4 tunnel (named in memory). The CR does not resolve which is canonical; the verification step in Piece 3 documents whichever responds.
Piece 1 fixes the friction surfaced during Phase 62's first day of real use. Three behaviors change: the version-bump operation gets a propose-and-approve scanner for self-references to the bumping version, the editor auto-reopens at the new versioned path after a bump, and the two save actions get visual treatment that distinguishes their consequence. All work in this piece lives in the loomworks-record repository — functions/api/save.ts, tools/static/editor.js, and the editor's CSS.
Piece 1 has no dependency on Piece 2 or Piece 3 and no dependency on the engine. It can ship independently if Pieces 2 and 3 halt for any reason.
Add a version-string scanning module at functions/api/_version_scanner.ts. The module exports two functions:
scanForVersionReferences(content: string, oldVersion: {major: number, minor: number}): VersionMatch[] — walks the content and returns matches in order of appearance.applyApprovedSubstitutions(content: string, approvals: ApprovalRecord[]): string — applies operator-approved substitutions atomically, returns transformed content.The VersionMatch type carries: { lineNumber: number, lineText: string, contextBefore: string[], contextAfter: string[], matchedText: string, proposedReplacement: string, patternFamily: "A" | "B" | "C" | "D" | "E" | "F", confidence: "high" | "medium" | "low" }. Pattern A/B/C matches return confidence "high"; D/E return "medium"; F returns "low" (cross-document references should default to declined).
Define scanner pattern recognition. The scanner walks the content line by line. For each line, it checks pattern families in order:
<title> elements containing v\d+\.\d+. Match if the version found equals the old version.<div class="doc-version-line"> or <div class="doc-meta"> or sidebar-subtitle elements containing the old version.<p> elements in the trailing third of the document containing the old version. (Footers are typically the last paragraph; using "trailing third" rather than "last paragraph" gives the scanner robustness to multi-paragraph footers.)"from v\d+\.\d+", "supersedes v\d+\.\d+", "v\d+\.\d+ to v\d+\.\d+". Returns medium confidence — operator must judge whether the reference is to the document being bumped or to historical predecessors.v0.1 → v0.2) and underscore filename form (v0_1, v0_2) where the value matches the old version.name-v\d+_\d+\.html or name-v\d+_\d+\.md where name differs from the document's own base filename. Low confidence — should default to declined unless operator approves.Should Pattern F matches (cross-document references) appear in the scanner's surface at all, or should they be filtered out entirely? Arguments either way: surfacing them gives the operator visibility into stale cross-references that might warrant a separate fix; filtering them out keeps the propose-and-approve surface focused on self-references. The CR proposes surfacing with low-confidence default-declined; execution-time can refine if the surface gets noisy.
Modify functions/api/save.ts to invoke the scanner during version-bump. Between line 152 (after currentContent is fetched) and line 161 (before githubMultiFileCommit):
scanForVersionReferences(body.content, {major: parsed.major, minor: parsed.minor}).body.approvals field is present in the request: return a 422 response with the matches array and a request to the client for approval. The client (editor.js) will present the approval UI and re-submit with body.approvals.body.approvals is present: call applyApprovedSubstitutions(body.content, body.approvals) and write the result to newPath.This two-step protocol keeps the Pages Function stateless while letting the client present the approval UI. The first POST sends content with no approvals field; the server scans and returns matches; the client presents the UI; the second POST sends the same content with approvals field populated; the server applies the approvals and proceeds with the commit.
Modify tools/static/editor.js to handle the 422 approval-required response. When saveDocument(payload) receives a 422 with a matches array:
approvals field populated as [{ matchIndex: number, decision: "approve" | "decline" }].The exact UI treatment of the approval modal — full-screen modal, side panel, inline expansion — is left to execution-time design. The requirement is that the operator can see surrounding context for each match (at least 3 lines before and after), can act on each individually, and can fast-handle high-confidence matches with one button. The visual treatment matching the letterpress palette is part of S1.4's execution.
The scanner module exists at functions/api/_version_scanner.ts with the two named exports. The module is unit-tested (see T1.1). Saving the architecture spec at v0.3 → v0.4 surfaces the footer reference at line 1677 (Pattern C, high confidence) and any other current-document v0.3 references; operator approves them; the bumped file lands with corrected references.
The scanner correctly distinguishes self-references from polysemic references. Specifically: a backward-reference of the form "we considered X in v0.1" in a document being bumped from v0.2 to v0.3 is surfaced as Pattern D medium-confidence, and the operator can decline it without affecting other approvals. A Pattern F cross-document reference defaults to declined; the operator can approve if intended.
Given a synthetic HTML document containing one Pattern A match, one Pattern B match, one Pattern C match (footer with old version), and one Pattern F cross-document reference at version that matches old version. When scanForVersionReferences(content, {major: 0, minor: 2}) is called. Then the result contains exactly 4 matches in document order; the Pattern A/B/C matches have confidence: "high" and the Pattern F match has confidence: "low"; the Pattern F match's matchedText contains the other-document filename, not just the version string.
Given a document containing the text "we considered X in v0.1 but rejected it" and being bumped from v0.2 to v0.3. When the scanner runs. Then no match is produced for the v0.1 reference (because v0.1 ≠ v0.2). And: Given the same document also contains the text "supersedes v0.2" with v0.2 matching the old version. When the scanner runs. Then exactly one Pattern D medium-confidence match is produced for the v0.2 occurrence, with the proposed replacement being v0.3.
Given the live architecture specification at v0.3 (with the known footer bug at line 1677). When the operator initiates a save-as-new-version operation to bump to v0.4. Then the approval UI surfaces the footer reference as a Pattern C high-confidence match with the proposed replacement v0.4. When the operator approves the match. Then the v0.4 file lands with the footer reading "v0.4" rather than "v0.3" (and not "v0.1" — meaning the v0.3 file produced by today's bump was created from a content state where the operator had hand-corrected the v0.1 to v0.3, or the operator has done that correction since).
Modify tools/static/editor.js at the success-handling branch of saveDocument (after line 184, before line 187). When payload.mode === "version-bump" and the save succeeded:
openEditFlow(json.newPath) to reopen the editor against the new versioned path.GET /api/document?path=... against the new path, picking up the just-written content and the new file's sha.Additionally: dispatch a history.replaceState call to update the URL bar's path component to the new versioned filename, so the browser's URL reflects the document the operator is now editing.
Should the editor preserve the operator's cursor position and scroll location across the modal close-and-reopen? Preserving them gives a smoother feel — the operator continues editing where they were. Resetting them is simpler to implement. The CR proposes resetting at first ship (the operator just saved; the next edit is typically a separate intent) and leaving cursor preservation for a future polish if it surfaces as friction.
Update the modal's "Saved" status message to reflect the new behavior. Current message says "Saved as new version. Commit X. New path: Y. Previous archived." Change to "Saved as v0.N. The editor is reopening at the new version." (with the actual version number interpolated).
After a successful version-bump save, the editor closes and reopens automatically pointing at the new versioned path within 2 seconds. The reopened editor shows the just-written content. The browser URL reflects the new path. The operator can continue editing the new version without manual navigation.
Given the operator has the editor open against some-doc-v0_1.html and has chosen "Save as New Version" with content modifications. When the save completes successfully and the approval step (if any) is past. Then within 2 seconds, the editor modal closes and reopens against some-doc-v0_2.html; the reopened editor's content matches what was just written; the browser URL contains some-doc-v0_2.html rather than some-doc-v0_1.html.
Rework the two save actions in the editor modal's footer. The current "Save (Minor Correction)" and "Save as New Version" buttons share visual weight. Change to:
The visual hierarchy matches the cognitive hierarchy: bumping versions is the substantive action; correcting in place is the smaller action.
Update the corresponding CSS in tools/static/editor.css (or equivalent stylesheet for the editor modal). Primary action: solid press-ink background, white text, 600 font-weight. Secondary action: 2px iron-oxide border, transparent background, iron-oxide text. Both follow the letterpress palette already established in the brand reference.
The two save actions in the editor modal are visually distinguishable at a glance. The primary action ("Save as v0.N") shows the destination version in its label. The operator looking at the buttons can tell which produces a new version and which doesn't without reading carefully.
Given the editor is open against a versioned file at v0.3. When the operator opens the save panel. Then the primary action's label reads exactly "Save as v0.4" (the next minor version); the secondary action's label reads exactly "Correct in place"; the primary action has solid background and the secondary has outlined border; the visual prominence of primary exceeds secondary.
End-to-end manual verification by the Operator. The Operator performs a real version-bump on a real document (a low-stakes document, such as a scratch document created for this purpose, rather than the architecture spec itself). Walks through: open the document; modify content; click "Save as v0.N"; review the scanner's approval surface; approve appropriately; confirm the new version lands; confirm the editor reopens at the new path; confirm the URL updates. Reports back any friction observed.
The Operator completes a real version-bump end-to-end without manual workarounds. No content fix-up required after the bump (the scanner caught the version references). The editor reopens at the new path without re-navigation. The friction the morning of 2026-05-23 surfaced is no longer present.
Given a real document in the loomworks-record repository at some version v0.N. When the Operator performs an end-to-end version-bump using the new editor. Then the resulting v0.N+1 file has correct internal version references (filename, title, version-line, footer); the editor is pointed at the v0.N+1 file when the modal reopens; no manual repository file editing was required to fix internal references.
Piece 2 wires every commit from the editor at record.dunin7.com to emit a FORAY narrative-event row to audit.foray_events. The substrate accepts new event types without schema migration (F0.1); the writer pattern is established by write_setting_change_event (F0.2); the best-effort discipline is uniform across existing call sites (F0.3). Piece 2 generalizes the pattern to editor_commit.
This piece spans both the loomworks-engine repository (the writer and event-type constant) and the loomworks-record repository (the call site in the Pages Function commit path). It depends on Piece 3's Decision A.1 service-token mechanism being in place — the Pages Function needs to authenticate to the engine to call the writer endpoint. Piece 2's execution interleaves with Piece 3's service-token setup; the CR sequences them in 2.A through 2.C below, with explicit interleaving noted.
Strengthening the best-effort discipline (A.5) to "audit row landing is required for editor commit to be acknowledged." Would require a transactional coupling between Cloudflare Pages and the engine database. Not absorbed; named for future consideration when audit completeness becomes load-bearing.
Add the event-type constant. In src/loomworks/audit/events.py, add a new constant alongside the existing EVENT_TYPE_SETTING_CHANGE at line 31:
EVENT_TYPE_EDITOR_COMMIT = "editor_commit"
Export the constant from the module's __init__.py alongside the existing exports.
Add the writer helper at src/loomworks/audit/events.py, immediately following write_setting_change_event. The signature follows the existing pattern:
async def write_editor_commit_event(
*,
actor_person_id: UUID,
document_path: str,
prior_content_hash: str | None,
new_content_hash: str,
commit_message: str,
commit_sha: str,
db: AsyncSession,
now: datetime | None = None,
) -> UUID:
Body constructs an AuditForayEventRow with event_type=EVENT_TYPE_EDITOR_COMMIT, actor_person_id as supplied, engagement_id=None (editor commits are not engagement-scoped today; see Open-2.1), and a payload of {"document_path": ..., "prior_content_hash": ..., "new_content_hash": ..., "commit_message": ..., "commit_sha": ...} coerced through _json_safe. tx_id = uuid.uuid4() following F0.2. Awaits db.flush(). Returns the tx_id.
Should editor commits carry an engagement_id? Today the loomworks-record repository is not modeled as a loomworks engagement in the engine. Two possible paths: (a) leave engagement_id=None and use the document_path payload field for scoping; (b) create a synthetic engagement representing record.dunin7.com itself and attribute all editor commits to it. Path (a) is simpler and lands first; path (b) is more idiomatic if record.dunin7.com later acquires engagement-shape (a "documentation engagement" pattern). The CR proposes path (a) at first ship; the engagement column is nullable per F0.1 so the path can change later without migration.
Write unit tests for the new writer at tests/audit/test_editor_commit_event.py. Tests should follow the pattern established in tests/audit/ for the setting-change writer if such tests exist; otherwise establish the pattern. Specifically: a happy-path test that constructs a row and verifies persistence; a JSON-safety test verifying non-encodable payload values are coerced via _json_safe; an error-path test verifying SQLAlchemyError propagates (the caller is responsible for catching, per A.5).
The EVENT_TYPE_EDITOR_COMMIT constant exists and is exported. The write_editor_commit_event function exists, follows the established pattern, has unit tests passing. The function can be imported by external callers (specifically, the new endpoint added in S2.4).
Given a test database with one Person row. When write_editor_commit_event is called with that person's UUID and a valid payload. Then a row appears in audit.foray_events with event_type = "editor_commit", the supplied actor_person_id, the payload populated, signature null, timestamp set by server default.
Add a new router at src/loomworks/api/routers/audit_editor_commit.py. The router exposes one endpoint:
POST /audit/editor-commit
Body: {
actor_person_id: UUID,
document_path: str,
prior_content_hash: str | None,
new_content_hash: str,
commit_message: str,
commit_sha: str,
}
Response (200): { tx_id: UUID }
Response (401): if service token missing or invalid
Response (422): if validation fails
The endpoint is protected by the service-token authentication established in Piece 3 (Section 3.A, S3.1-S3.4). It validates the body via a Pydantic request model, calls write_editor_commit_event, and returns the resulting tx_id.
Register the router in src/loomworks/api/app.py within _register_routers(app). Place alongside other audit/admin routers, following the existing registration pattern.
The endpoint POST /audit/editor-commit exists. A valid request with proper service-token auth lands a row in audit.foray_events and returns the tx_id. An invalid token returns 401. A malformed body returns 422.
Given the engine running with LOOMWORKS_ENGINE_SERVICE_TOKEN configured. When a POST to /audit/editor-commit is made with the correct Authorization: Bearer header and a valid body. Then the response is 200 with a tx_id; a corresponding row exists in audit.foray_events. And: Given the same endpoint. When the POST has no Authorization header or has an incorrect token. Then the response is 401 and no row is created. And: Given the same endpoint. When the POST has correct auth but is missing the actor_person_id field. Then the response is 422 and no row is created.
Modify functions/api/save.ts to call the engine audit endpoint after every successful commit. The call happens after githubMultiFileCommit(...) returns successfully and before the response is returned to the client. In both code paths (version-bump path lines 161-170 and minor-correction path elsewhere in the file):
currentContent.content (in the version-bump path) or by fetching the prior version via the GitHub API (in the minor-correction path, where the prior content was just overwritten).POST /audit/editor-commit with the operator UUID (resolved at session start per Piece 3), the document path, the prior and new content hashes, the commit message, and the commit SHA returned from GitHub.Add a helper in functions/api/_shared.ts for the audit-emit call. async function emitEditorCommitAudit(env, payload) takes the Pages Function's env object (for the service token and engine base URL) and the payload, performs the POST, returns the tx_id on success or throws on failure. The caller in save.ts wraps the call in try/catch.
Add Cloudflare Pages secrets. Two new environment variables on the Cloudflare Pages project for record.dunin7.com:
LOOMWORKS_ENGINE_BASE_URL — the production URL of the engine (e.g. https://api.loomworks.doneinseven.com). Set per A.6's deferred resolution.LOOMWORKS_ENGINE_SERVICE_TOKEN — the service token from A.1.This step is an operational task for the Operator, not CC: secrets are added through the Cloudflare dashboard rather than via code.
Before Piece 2's call site can function, the Operator must: generate a secure random service token (e.g. 32-byte URL-safe random); add it to Cloudflare Pages secrets as LOOMWORKS_ENGINE_SERVICE_TOKEN; add it to the engine's environment (wherever the engine reads its env vars in production) under the same name. The CR proposes this happen between S2.5 (engine endpoint exists with auth) and S2.6 (Pages Function calls endpoint).
Operator decides: which random-generation tool to use (any cryptographically-secure source), and the operational flow for adding to the engine environment (depends on Phase 51's deployment mechanism; the Operator knows the deployment posture better than CC).
Every successful commit through the editor at record.dunin7.com results in a row in audit.foray_events within ~1 second of the commit landing. The row's actor_person_id is the operator's engine person UUID; the payload's commit_sha matches the GitHub commit SHA; the payload's document_path matches the path the operator edited.
Audit-write failures do not block commits. If the engine is unreachable, returns an error, or otherwise fails to land the row, the commit still completes and the editor still shows success to the operator. The failure is logged in Cloudflare logs for later review.
Given the editor at record.dunin7.com with the engine reachable and authenticated. When the operator makes any commit (version-bump or minor correction). Then within 5 seconds, querying audit.foray_events shows a new row with event_type = "editor_commit", actor_person_id matching the operator's UUID, payload's commit_sha matching the GitHub API response, and payload's document_path matching the edited file.
Given the editor at record.dunin7.com configured with an intentionally wrong engine URL (so the audit-emit call will fail). When the operator makes a commit. Then the commit lands successfully in the GitHub repository; the editor shows success to the operator; the Cloudflare Pages logs contain an error-level entry naming the failed audit emit.
Piece 3 builds the surface where the Operator (or anyone with access) can read FORAY narrative events back. The viewer lives on record.dunin7.com as a new section between the existing scoping-notes and operations sections. It depends on five new engine endpoints (email-resolution from A.2 plus four audit-read endpoints), the service-token mechanism (A.1), and engine production reachability (A.6).
Piece 3 is the largest piece. It interleaves with Piece 2 — the service-token mechanism (3.A) must land before Piece 2's call site can call the audit endpoint. The CR sequences 3.A first within Piece 3, with explicit interleaving with Piece 2 noted at the relevant steps.
Verify engine is reachable from outside the M4. The Operator and CC together determine the engine's current production reachability. Specifically: attempt to curl the engine's health endpoint (or any unauthenticated GET endpoint) from a host outside the M4's local network — e.g. from a Cloudflare Pages Function preview environment, or from Claude.ai's web fetch tool. Document the response.
Based on the result of S3.1:
api.loomworks.doneinseven.com or api.loomworks.dunin7.com with a sensible response: proceed with Piece 3 as written. Document the canonical URL in the engine deployment manifest.This is a load-bearing decision. The CR continues below assuming Path A from DR-3.1 (engine reachable, proceed as written). If Path B or C is chosen, the steps below transform into a Phase 63.5 or 64 scope.
Configure engine CORS. Add https://record.dunin7.com to the engine's LOOMWORKS_CORS_ORIGINS environment variable. Add Authorization to the allow_headers list in src/loomworks/api/app.py (the current list is ["Content-Type"] per F0.5; change to ["Content-Type", "Authorization"]). Engine restart required for env-var change; code change deploys via the engine's normal deployment path.
Add the service-token authentication dependency in src/loomworks/api/deps.py. New function require_service_token(authorization: str = Header(None)) that extracts the bearer token from the Authorization header, compares against settings.loomworks_engine_service_token (a new settings field), raises 401 on mismatch, returns nothing on match.
Settings field defined in src/loomworks/config.py alongside existing settings: loomworks_engine_service_token: str = "". Read from env var LOOMWORKS_ENGINE_SERVICE_TOKEN. If empty (not configured), the dependency rejects all requests (so the absence of a configured token can never accidentally allow unauthenticated access).
Write unit tests for the service-token dependency at tests/api/test_service_token_auth.py. Tests: dependency rejects missing token (401); rejects wrong token (401); accepts correct token (no exception). The test must configure loomworks_engine_service_token through the test fixtures, not from the actual env.
The service-token authentication mechanism exists and is tested. CORS allows Authorization header and https://record.dunin7.com origin. Engine is reachable from record.dunin7.com's Pages Functions (verified by S3.1's response or by a fresh call after CORS update).
Given the engine deployed with CORS updated and a configured service token. When a CORS preflight (OPTIONS) request is made from https://record.dunin7.com for a protected endpoint with Authorization header. Then the response allows the origin and includes Authorization in Access-Control-Allow-Headers. When a GET request follows with the correct token. Then the endpoint proceeds as expected (not 401, not CORS-blocked).
Add a router at src/loomworks/api/routers/persons_resolve.py with one endpoint:
POST /persons/resolve-email
Body: { email: str }
Response (200): { person_id: UUID, email: str }
Response (404): if no person matches
Response (401): if service token missing or invalid
The endpoint depends on require_service_token (S3.3). It calls get_person_by_email (F0.6) and returns the resulting UUID. Returns 404 if no match. Per F0.6 the substrate returns first-match-by-created_at on non-unique emails; the endpoint accepts this semantics per A.2.
Register the router in src/loomworks/api/app.py within _register_routers(app). Place alongside other person/identity routers.
Write unit tests at tests/api/test_persons_resolve.py. Tests: 401 on missing/wrong service token; 200 with correct token and matching email; 404 with correct token and non-matching email; first-match semantics verification (insert two persons with same email at different created_at, verify earlier one is returned).
The POST /persons/resolve-email endpoint exists with the contract described in S3.5. Unit tests pass. The endpoint is callable from record.dunin7.com's Pages Functions with the service token.
Given the engine with one Person row whose email is "marvinp@dunin7.com" and known UUID. When a POST to /persons/resolve-email is made with the correct service token and body {"email": "marvinp@dunin7.com"}. Then the response is 200 with {"person_id": "<uuid>", "email": "marvinp@dunin7.com"}. And: Given the same setup. When a POST is made with body {"email": "someone-else@example.com"}. Then the response is 404.
The audit read API is the core data source for the viewer. Four endpoints, all under the service-token auth, all cursor-paginated following the dashboard.py convention (F0.4). Sequenced to land together since they share helpers and pagination infrastructure.
Add a router at src/loomworks/api/routers/audit_read.py. The router exposes four endpoints:
GET /audit/events
Query: ?limit=N (1-100, default 50), ?cursor=opaque,
?event_type=editor_commit (optional filter),
?actor_person_id=UUID (optional filter),
?since=ISO8601 (optional filter), ?until=ISO8601 (optional)
Response: { events: [...], next_cursor: str | null }
GET /audit/events/{tx_id}
Response: full event row by transaction id
GET /audit/events/by-document?document_path=...
Query: same pagination as /audit/events
Response: events filtered to a specific document_path
(filters payload.document_path field for editor_commit events)
GET /audit/events/summary
Response: counts grouped by event_type, by actor, by recent date buckets
(used by the viewer's landing page)
All endpoints depend on require_service_token. All carry standard Pydantic response models. Pagination uses the opaque-cursor convention from F0.4: cursor is base64-URL JSON of {"ts": "<iso8601>", "id": "<uuid>"}, where ts is the row's timestamp and id is the row id for tiebreaking on simultaneous events.
Define the response models. A AuditEventResponse Pydantic model with fields matching the row schema (id, tx_id, event_type, actor_person_id, engagement_id, payload, timestamp). Signature field is intentionally omitted from the response — it's binary and not useful in JSON; if a future viewer needs it, a separate endpoint can expose it. AuditEventListResponse wraps a list of events plus next_cursor.
Implement cursor encoding/decoding helpers in src/loomworks/api/_audit_cursor.py. Two functions: encode_cursor(ts: datetime, id: UUID) -> str and decode_cursor(cursor: str) -> tuple[datetime, UUID]. Base64-URL JSON encoding per F0.4. Raise ValueError on malformed cursor.
Implement the query logic for each endpoint. GET /audit/events: build a SQLAlchemy select against AuditForayEventRow, apply optional filters (event_type, actor_person_id, since/until), apply cursor-based pagination ordering by (timestamp DESC, id DESC), limit to limit + 1 rows to detect "is there a next page", return up to limit events plus a next_cursor if a next page exists. The four indexes on the table (F0.7) cover the expected query shapes.
Implement the by-document filter. GET /audit/events/by-document filters on payload->>'document_path' = :document_path at the SQL level. Postgres JSONB operator. Combine with the other optional filters; combine with cursor pagination. Add an index on (payload->>'document_path', timestamp DESC) via a new alembic migration if the existing indexes don't cover this query (the four existing indexes F0.7 do not include a payload-keyed one).
Should the by-document index be added now, in a fresh migration, or deferred to a future phase when query-performance becomes load-bearing? Arguments either way: without the index, queries on a few thousand editor commits will be fast enough; the index can land later if needed. With the index, the query plan is durable across data growth. The CR proposes adding the index now — the migration is small (one CREATE INDEX statement), it lands cleanly alongside the other Phase 63 work, and deferring it would require remembering to do it later.
Add the by-document index migration at migrations/versions/00XX_audit_payload_document_path_index.py (next available number). The migration creates a B-tree index on ((payload->>'document_path'), timestamp DESC). Reversible — the downgrade drops the index.
Implement the summary endpoint. GET /audit/events/summary returns three aggregations: counts by event_type (over all time); counts by actor_person_id (top 10 by event count); counts by date bucket (last 7 days, last 30 days, last 90 days, all time). Used by the viewer's landing page to give a quick overview before drilling into specifics.
Register the router in src/loomworks/api/app.py. Write unit tests at tests/api/test_audit_read.py covering: all four endpoints, all filter combinations, pagination correctness (cursor returns same results, next_cursor is null on last page), edge cases (empty result set, malformed cursor returns 422).
All four audit-read endpoints exist with their contracts. Unit tests pass. Cursor pagination works correctly (no missing events, no duplicates across pages). The by-document index exists.
Given the engine seeded with 120 editor_commit events spanning the last 90 days, with three distinct document_path values. When GET /audit/events?limit=50 is called with correct auth. Then the response carries 50 events in descending-timestamp order; next_cursor is non-null. When the same call is made with the returned cursor as ?cursor=.... Then the response carries the next 50 events with no overlap with the first page. When filtered GET /audit/events/by-document?document_path=/path/to/doc.html. Then only events with that document path are returned. When GET /audit/events/summary is called. Then the response carries counts by event_type, top 10 actors, and date-bucket counts that align with the seeded data.
Add a helper in functions/api/_shared.ts for UUID resolution. async function resolveOperatorUUID(env, email): Promise<string> calls the engine's POST /persons/resolve-email endpoint with the service token, returns the person UUID on 200, throws on 404 or 401.
Add a session-init endpoint at functions/api/session-init.ts exposed at GET /api/session-init. The endpoint authenticates the Cloudflare Access email (per F0.6's mechanism), calls resolveOperatorUUID(env, email), returns the UUID to the client. The editor calls this endpoint once when first loaded.
Modify tools/static/editor.js to call /api/session-init at editor load and cache the returned UUID in a module-level variable. The cached UUID is included in subsequent POST /api/save calls. If session-init fails (engine unreachable, email unresolvable), the editor displays a warning at the top of the modal — commits will still proceed but audit rows will fail. This matches A.5's best-effort discipline.
When the operator opens the editor, the operator UUID is resolved and cached within 1 second. Subsequent commits within the session reuse the cached UUID without re-calling the engine. If resolution fails, the editor displays a clear warning and commits still proceed.
Given the engine with the Operator's Person row configured. When the operator opens the editor for any document. Then the browser network panel shows a single GET to /api/session-init returning the operator's UUID; subsequent commits include this UUID in their request bodies; no additional session-init calls happen until the page is reloaded.
Add an audit-viewer section to the record.dunin7.com static-site build. In tools/build_site.py, add a new section entry ("audit", "Audit", "FORAY narrative events...") following the existing pattern. The section gets a card on the landing page like the others.
Add the audit-viewer page at audit/index.html. The page is a single-page client-rendered viewer. Initial load shows the summary endpoint's response (counts by event type, top actors, date buckets). The page has three primary views:
audit/?document_path=.... Shows events filtered to one document. Linked from each document's own viewer page (S3.21).Add per-document audit links. In tools/build_site.py, modify the document-viewer template to include a small "View audit history" link in the document's chrome (next to the breadcrumb or in a sidebar). The link routes to /audit/?document_path=<path>.
Write the audit-viewer client JS at tools/static/audit-viewer.js. Plain vanilla JS, no framework — matches the existing tools/static/ idiom. Calls the engine's audit-read endpoints (via the Pages Function as a proxy, see S3.23). Renders events into a templated list. Handles cursor pagination via "Load more" button. Renders filter controls and propagates changes to URL params.
Add a Pages Function proxy for audit reads at functions/api/audit.ts. The proxy receives requests from the audit-viewer client, adds the service-token Authorization header, forwards to the engine's audit endpoints, returns the response. The client never sees the service token; the token never leaves the Pages Function environment.
Routes proxied: /api/audit/events, /api/audit/events/:tx_id, /api/audit/events/by-document, /api/audit/events/summary. Query parameters and cursors pass through unchanged.
Should the audit viewer require authentication to view, or be readable by anyone with Cloudflare Access to record.dunin7.com? Today record.dunin7.com is gated by Cloudflare Access, so "readable by anyone with Access" is already a meaningful restriction. The CR proposes no additional gating at first ship — the viewer reads what Access-authenticated users can see, and Access provides the authentication. A future phase can introduce viewer-side permissions if record.dunin7.com opens to broader audiences.
Add CSS for the audit viewer matching the letterpress palette. Event entries use vellum background with brass left-border; event_type badge uses iron-oxide tint matching the FORAY-event identity. Filter controls follow the existing scoping-notes-section visual pattern. Page layout matches the project's standard three-region structure (header, main, footer).
The audit viewer at record.dunin7.com/audit/ renders FORAY narrative events. Default view shows newest-first list. Filtering by date range, actor, document_path works and composes. Pagination works (Load more loads next page without duplicates or gaps). Per-document deep-link from any document's viewer page lands on the audit viewer filtered to that document.
Following a commit through the editor, that commit becomes visible in the audit viewer within ~5 seconds (the time for the audit row to land and for the operator to navigate to the viewer). The audit-row-to-viewer pipeline is functional end-to-end.
Given the engine with 50 editor_commit events seeded. When the Operator navigates to record.dunin7.com/audit/. Then the page loads within 3 seconds; the default view shows the 50 events in newest-first order; pagination works (Load more reveals additional events if any; on a 50-event seed with default limit 50 there are no additional events and Load more is hidden). When the Operator applies a document-path filter for a specific document. Then only events for that document show. When the Operator clicks "View audit history" from a document's viewer page. Then the audit viewer loads with that document's path pre-filtered.
Given all of Phase 63 deployed and functional. When the Operator opens the editor for any document, makes a small commit (minor correction), and navigates to record.dunin7.com/audit/. Then within 5-10 seconds, the just-committed event appears at the top of the audit viewer's default view; its commit_sha matches the GitHub commit; its document_path matches the edited file; its actor matches the Operator.
Phase 63 follows the project's standard phase-close sequence. The close protocol is enumerated here so close-time work has a predictable shape.
Verify all acceptance gates. Walk G1.1 through G3.6 in order. For each gate, confirm the gate holds with the corresponding test passing. Any gate that does not hold becomes a close-blocker; either the gate is met (further work) or the gate is explicitly deferred with rationale (re-scope decision).
Produce implementation notes. File phases/phase-63-editor-refinement/loomworks-phase-63-implementation-notes-v0_1.md in the loomworks-record repository. Notes cover: what landed, what was deferred and why, any open questions remaining, any methodology contributions surfaced during execution, any boundary cases observed in the visual classification system that warrant systemwide refinement.
Tag both repositories at phase-63-editor-refinement-with-foray-audit. The tag points at the engine commit and the loomworks-record commit that together constitute the Phase 63 ship. If the engine and loomworks-record need different tags (e.g. record.dunin7.com lands first while engine work is being verified), each repo gets its tag at its own commit; the implementation notes document both SHAs.
Trigger manifest absorption. Open a separate session for the v0.41 → v0.42 manifest absorption. The absorption includes Phase 63 plus the still-deferred Phase 57 close-out plus the eleven-phase methodology candidate backlog. Manifest absorption is its own scope and is not absorbed into Phase 63's close.
All Phase 63 acceptance gates hold or are explicitly deferred with rationale. Implementation notes are filed. Both repositories tagged. Manifest absorption is queued as the next session's work.
Named explicitly so the boundary of Phase 63 is visible without having to read the inverse of all the execution steps.
FORAY anchoring layer. No Kaspa connection, no signing primitives, no anchor-batch pipeline. Editor-commit FORAY rows accumulate without anchoring; they will gain anchoring when that work eventually lands as its own phase or arc. The signature column is reserved-but-empty (F0.2).
Cross-substrate FORAY consolidation. Per A.3, the audit viewer reads narrative events only. Value flows and memory events stay independent. Unification is Future-A.3.
OVA development. Phase 63 uses opaque UUID identity throughout. OVA's seam-and-stub work continues on its own track.
JWT-based identity at the Pages Function. Per A.1 and A.2 and their paired Future markers, the shared service token plus email-resolution endpoint are explicit single-Operator simplifications. JWT propagation and Access-sub-based identity are deferred to their own arc.
Email-uniqueness enforcement at the database layer. Phase 63 accepts the engine's existing first-match semantics per A.2. A future phase will introduce a constraint and a migration if multi-Operator scope requires it.
Strengthening best-effort audit-write discipline. Per A.5 and Future-2.1, editor commits remain authoritative regardless of audit-row landing. Reversing this discipline would require transactional coupling between Cloudflare Pages and the engine database; not absorbed.
The smaller Phase 62 polish findings unrelated to the version-bump operation. Specifically: new-document creation accepting only a slug; the Monaco loader double-load warning. These remain queued for a future polish phase.
The architecture specification update that the editor friction originally surfaced. Phase 63 makes the editor trustworthy enough for that update; the update itself is a separate piece of substantive content work.
Engine deployment work. Per A.6, if engine reachability fails at DR-3.1, deployment work is named as a separate scope rather than absorbed.
This CR is the first artifact to use the eight-category visual-classification system. Boundary cases observed during drafting are recorded here for the cross-check cycle's consideration.
Settled decisions sometimes carry paired Future-work markers (A.1↔Future-A.1, A.2↔Future-A.2, A.3↔Future-A.3) but not always (A.4, A.5, A.6 are uniformly applicable and have no paired future). The convention seems to be: pair when the settled decision is a bounded simplification with a known replacement; omit when the decision is project-wide. This held cleanly through Section A but should be watched as more CRs use the system.
The boundary between Execution and Decision Required is sometimes blurry. S3.1 (verify engine reachability) is mechanical but DR-3.1 (which path to take based on the result) is operator-decision. The CR handled this by sequencing them as adjacent elements: the mechanical step, then the decision that depends on its result. This works when the decision happens at a discrete moment; it may stretch when execution and decision interleave more tightly.
The CR distinguishes Acceptance Gates from Tests as separate categories, but they are tightly coupled — every gate is verified by one or more tests. The convention used here is: gate states the testable condition in operator-language; test states the mechanism in given/when/then. Both are useful, but the visual separation may be more weight than needed. Worth watching across future CRs.
Open questions appear in multiple places — within pieces (Open-1.1, Open-2.1, Open-3.1, Open-3.2) and as meta-observations about the classification system itself (Meta-1 through Meta-4). The CR conflates these two kinds with a single visual treatment. Consider whether meta-observations warrant their own category or whether the Open category is broad enough to hold both.