DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/phase-63-editor-refinement/loomworks-phase-63-step-0-findings-v0_1.html
DUNIN7 · Loomworks · Phase 63 · Step 0 Findings

Pre-flight Verification Findings

11 verifications executed across loomworks-engine and loomworks-record
v0.1·2026-05-23·Working draft
What this document is

The findings report from the Phase 63 Step 0 inspection. The inspection brief at phases/phase-63-editor-refinement/loomworks-phase-63-step-0-inspection-brief-v0_1.html named 11 verifications to be executed against current code state. This document reports outcomes for each one.

Inspection scope. loomworks-engine at commit 2f04e7a; loomworks-record at commit 0e8b272. Verifications V1–V6 and V11 targeted the engine; V7–V9 targeted the record; V10 spanned both.

Overall character. Mostly confirmed. 8 of 11 verifications returned CONFIRMED with no material caveats. Two returned NUANCED, surfacing real complexity that the scoping note v0.1 did not yet capture. One returned CONTRADICTED in part, identifying a substantial gap in the scoping note's identity-flow assumptions. Scoping note v0.2 absorbs all three deltas.

V1 Confirmed

The existing setting-change writer pattern

There is exactly one writer helper in src/loomworks/audit/events.py:

  • File: /Users/dunin7/loomworks-engine/src/loomworks/audit/events.py
  • Event-type constant (line 31): EVENT_TYPE_SETTING_CHANGE = "setting_change"
  • Function signature (lines 52-61):
async def write_setting_change_event(
    *,
    actor_person_id: UUID,
    setting_key: str,
    previous_value: Any,
    new_value: Any,
    engagement_id: UUID | None,
    db: AsyncSession,
    now: datetime | None = None,
) -> UUID:

Payload shape convention (lines 79-83) is a flat JSONB dict whose keys are documented in the model docstring (models.py:37) — {"setting_key": str, "previous_value": Any | None, "new_value": Any} — coerced through _json_safe(...) (lines 34-49) which falls back to str(value) for non-JSON-encodable inputs.

Discipline notes from the docstring: caller wraps in try/except SQLAlchemyError; failures are swallowed not propagated. signature left None today — Kaspa-anchoring is the next-layer ship; column reserved so signing can be added without a schema migration.

Tx mapping: tx_id = uuid.uuid4() — today every narrative event has a 1:1 row:tx mapping; future multi-row events can share a tx_id. The function does not flush its own transaction; the caller's db.begin() block atomically commits the substrate write and the audit row.

Example call-site

From src/loomworks/orchestration/tune_setting.py:83-99:

try:
    await write_setting_change_event(
        actor_person_id=actor_person_id,
        setting_key=setting_key,
        previous_value=previous_value,
        new_value=new_value,
        engagement_id=engagement_id,
        db=db,
    )
except SQLAlchemyError:
    logger.exception(
        "FORAY audit write failed for setting_change "
        "(person=%s, key=%s); continuing — setting write is "
        "authoritative",
        actor_person_id,
        setting_key,
    )
V2 Confirmed

The audit.foray_events table shape

Adding editor_commit as a new event_type requires no schema migration.

  • File: /Users/dunin7/loomworks-engine/migrations/versions/0068_audit_foray_events.py
  • Schema: audit (created in this migration; op.execute("CREATE SCHEMA audit") line 34)
  • Table: audit.foray_events

Columns (lines 36-82)

Column Type Nullable Default
idUUIDNOT NULLgen_random_uuid()
tx_idUUIDNOT NULL
event_typeVARCHAR(64)NOT NULL
actor_person_idUUIDNOT NULL
engagement_idUUIDnullable
payloadJSONBNOT NULL'{}'::jsonb
signatureLargeBinary (BYTEA)nullable
timestampTIMESTAMPTZNOT NULLnow()

Indexes (lines 84-110): tx_id; (actor_person_id, timestamp DESC); (event_type, timestamp DESC); (engagement_id, timestamp DESC).

Constraints that would block "editor_commit" as a value: none. No CHECK on event_type; no enum type; VARCHAR(64) accepts any string up to 64 chars. "editor_commit" is 13 chars — well within bounds.

Migration docstring states explicitly (lines 7-12): "v0.1 ships one event type (setting_change); the table shape is structurally extensible to future 'metadata-not-content' intents (role changes, identity changes, workspace operations per the parent CR §F) by adding new event_type values + payload schemas." Discriminator-column comment (line 50): "Future: 'role_change', 'identity_change', 'workspace_operation', etc."

actor_person_id is NOT NULL — Phase 63 must supply a valid person UUID for editor commits (see V10 below for the resolution gap). engagement_id is nullable, fits editor commits which are not engagement-scoped today.

V3 Confirmed

The best-effort writing convention

Uniform across both call sites.

Call site 1: src/loomworks/orchestration/tune_setting.py (Companion-spoken path)

Wrapped through a thin helper _audit_setting_change (lines 65-99). Helper docstring (lines 74-82) names the discipline explicitly: "A failure here MUST NOT abort the setting write — the setting change is already authoritative at this point. Log the exception and swallow it so the handler still returns its tuned/reset operation_data."

Helper called from both numeric (line 596) and enum (line 489) dispatch paths.

Call site 2: src/loomworks/api/routers/me_settings.py (button-clicked PUT path)

Lines 116-131:

try:
    await write_setting_change_event(
        actor_person_id=person.id,
        setting_key=setting_key,
        previous_value=previous,
        new_value=normalized,
        engagement_id=None,
        db=db,
    )
except SQLAlchemyError:
    logger.exception(
        "FORAY audit write failed for PUT /me/settings/%s "
        "(person=%s); continuing — setting write is authoritative",
        setting_key,
        person.id,
    )

Both sites: try/except SQLAlchemyError; logger.exception(...); continue. No re-raise. Identical pattern. The convention is uniform.

One small structural difference (not load-bearing): tune_setting.py factors the try/except into a private helper; me_settings.py inlines it. Same semantics either way.

V4 Confirmed

The engine's existing API router conventions

FastAPI. Pydantic response models. Cookie + bearer dual-path auth. Opaque-cursor pagination.

  • Framework: FastAPI (confirmed in src/loomworks/api/app.py:17: from fastapi import Depends, FastAPI).
  • Factory: create_app(*, settings: Settings | None = None) in app.py:367; registers ~45 routers via private _register_routers(app) (lines 538-820); the Operator Layer block (Phase 40+) mounts under prefix /operator (line 763 onwards).
  • Routers directory: src/loomworks/api/routers/ — 50+ files.

Five named existing routers

  1. engagements.py — engagement creation & lifecycle
  2. engagement_list.pyGET /engagements, membership-scoped list
  3. me_settings.pyGET / PUT /me/settings/{setting_key} (V1/V3's writer call site)
  4. dashboard.py — Phase 39 cross-engagement aggregation, three endpoints, cursor-paginated
  5. admin_grants.py — admin grant list/audit, cursor-paginated

Auth dependencies (defined in src/loomworks/api/deps.py)

  • get_current_person (deps.py:501-554) — session-cookie path; raises 401 on missing/invalid/unverified-totp; returns Person
  • get_current_person_optional (deps.py:410-445) — same as above but returns None on absence
  • get_current_contributor (deps.py:557-619) — dual-path (cookie OR Authorization: Bearer) → returns a Contributor
  • get_committing_contributor (deps.py:737-756) — restricts to kind="human" AND commit_authority=True
  • get_resolved_actor (deps.py:810-891) — newer unified shape returning ResolvedActor carrying both actor_ref and can_commit

Example route with auth dependency

From me_settings.py:73-81:

@router.put(
    "/me/settings/{setting_key}", response_model=SettingResponse
)
async def put_setting_route(
    setting_key: str,
    body: SettingRequest,
    person: Person = Depends(get_current_person),
    db: AsyncSession = Depends(get_db_session),
) -> SettingResponse:

Pagination pattern: opaque-cursor

Two known implementations:

  • dashboard.py (Phase 39, CR §7) — every endpoint accepts ?limit (1..100, default 50) and ?cursor (opaque base64-URL JSON {"ts": "<iso8601>", "id": "<uuid>"}). Encoders/decoders at lines 61-76.
  • admin_grants.py:222-300 — mirrors Phase 39 cursor convention so the frontend cursor handler can be reused.

Some assertion-listing endpoints use limit/offset directly (e.g. assertions.py:386), but cursor is the canonical pattern for newer routes and would be the natural choice for an event_type=editor_commit audit-feed endpoint.

V5 Nuanced

The engine's API host and exposure

Engine deployment at api.loomworks.doneinseven.com is named in scoping but not yet reliably live; CORS infrastructure exists and is configured to allow an unspecified set of production origins via env var.

This nuance is recorded in Phase 51/52 implementation notes, not a contradiction of the brief — the brief itself names this premise. But it surfaces as material for Phase 63: the editor's Pages Function will be calling an engine that may not yet exist in production.

Host configuration

  • src/loomworks/config.py:18-19loomworks_http_host: str = "127.0.0.1", loomworks_http_port: int = 8000. Production binding is at deployment layer, not in code.
  • Production engine URL named in scoping per docs/phase-impl-notes/phase-51-implementation-notes-v0_2.md: https://api.loomworks.doneinseven.com. Memory note in this conversation context mentions api.loomworks.dunin7.com via M4 tunnel; the two doneinseven/dunin7 strings appear in different phase impl notes.
  • Authority instance URL field (config.py:77-89): loomworks_authority_instance_url: str = "" — empty-string default, "not yet populated" per Phase 52 note 151.
  • Engine deployment status per Phase 52 §15 gate 7: "deferred-until-deployment annotation" — engine still not live at Phase 52 close, and no memory entry confirms it's now live.

CORS configuration (src/loomworks/api/app.py:386-419)

cors_origins: list[str] = [
    o.strip()
    for o in (settings.loomworks_cors_origins or "").split(",")
    if o.strip()
]
if settings.loomworks_env in ("development", "test"):
    cors_origins = [
        "http://localhost:3000",
        "http://localhost:3001",
        *cors_origins,
    ]
if cors_origins:
    app.add_middleware(
        CORSMiddleware,
        allow_origins=cors_origins,
        allow_credentials=True,
        allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
        allow_headers=["Content-Type"],
    )
  • Env var: LOOMWORKS_CORS_ORIGINS (comma-separated). Dev/test envs auto-include localhost:3000 and :3001; production envs use only the env-var list.
  • allow_credentials=True, allow_methods includes POST/PUT/PATCH and OPTIONS; allow_headers=["Content-Type"] — note this does not include any custom auth header.

Authentication mechanisms

  • WebAuthn + TOTP yielding a session cookie (get_current_person chain)
  • Bearer-token via Authorization header (get_current_contributor Path 2) — engagement-scoped contributor tokens
  • Dev-only /auth/dev/issue-session (app.py:811-820) — gated to loomworks_env in ("development", "test") ONLY

What would block a Cloudflare Pages Function at record.dunin7.com calling the engine API directly

  1. Engine not yet deployed in production. Memory entry "Phase 51 closed" suggests engine lives at "api.loomworks.dunin7.com via M4 tunnel" — but Phase 51/52 impl notes treat this as future work.
  2. CORS origin allowlist must list https://record.dunin7.com in LOOMWORKS_CORS_ORIGINS once the engine is reachable.
  3. allow_headers=["Content-Type"] — the Pages Function cannot pass a custom header (e.g. Authorization) without engine-side widening of this list.
  4. No matching auth mechanism for service-to-service calls today. Bearer tokens are engagement-scoped contributor tokens; session cookies are WebAuthn+TOTP-issued and not portable to a server-to-server context. A Pages Function calling the engine would need either (a) a new service-token mechanism, (b) some shared secret config, or (c) the engine to honor Cloudflare-Access JWT directly. None of those exist today.
  5. Operator UUID resolution endpoint does not exist (see V10) — even if connectivity worked, the Pages Function has no way to ask the engine "what UUID is this email?".

Load-bearing for Phase 63: The "missing service-to-service auth" gap (#4) and the "no UUID resolution endpoint" gap (#5) together mean Phase 63 Piece 3 must scope at least one of (a) a new engine endpoint + auth mechanism, or (b) some alternate path for the Pages Function to learn the operator UUID. The CORS and engine-deployment items are operational; the auth/endpoint items are CR-level.

V6 Confirmed

Whether any read-API surface for FORAY tables exists

No HTTP read endpoint exists for FORAY tables — neither audit.foray_events nor credit.foray_action_flows.

Search command run: grep -rn "foray_events\|audit\." src/loomworks/api/ src/loomworks/orchestration/routers/. Result: no HTTP route references audit.foray_events; the only mentions are the write paths from V3 and documentation comments. No router file reads from the table.

Search command for credit.foray_action_flows: similar result — the table is referenced from credit module's internal business logic, but no HTTP route. The reconciliation evaluator (background task started in app.py:303-307) reads from it internally; nothing exposes it externally.

Internal selects that could be lifted into an API surface

For audit.foray_events, no internal select exists at all today — the table is write-only post-migration. Adding a read endpoint for Piece 3 (the audit viewer) is greenfield. The four indexes on the table anticipate exactly the kinds of queries a read endpoint would do:

  • "show me my events" → use actor_person_id+ts DESC index
  • "show me all editor_commit events" → use event_type+ts DESC index
  • "show me events in this engagement" → use engagement_id+ts DESC index

This confirms the brief's premise: Piece 3 must build the read endpoint from scratch; the table's index choices already pre-imagine the query shapes.

V7 Confirmed

Current version-bump implementation in the editor

No content transformation happens — the editor sends content unchanged; save.ts archives the old content unchanged.

  • File: /Users/dunin7/loomworks-record/functions/api/save.ts
  • New content source: request body field body.content (line 62), passed from the editor's editor.getValue() call (tools/static/editor.js:154).
  • Version-bump code path: lines 104-181.

Key sub-steps of the version-bump path

  1. Parse the filename (line 109): const parsed = parseVersionedFilename(filename); — returns {base, ext, major, minor, patch}.
  2. Compute the new version (line 120): const newParsed = { ...parsed, minor: parsed.minor + 1, patch: 0 }; — bumps minor by 1, resets patch to 0. Comment confirms: "Phase 62 supports minor bumps only (v0.1 -> v0.2). Patch and major bumps are future-facing affordances."
  3. Compute paths (lines 121-123):
const newFilename = buildVersionedFilename(newParsed);
const newPath = `${folder}${newFilename}`;
const archivePath = `${folder}archive/${filename}`;
  1. Collision check (lines 125-141): if newPath exists, return 409.
  2. Fetch current content (lines 143-152): currentContent = await githubGetFile(env, validatedPath); — used to copy unchanged to archive.
  3. Atomic multi-file commit (lines 161-170):
const commitSha = await githubMultiFileCommit(
  env,
  [
    { path: newPath, content: body.content },                  // new content from request
    { path: archivePath, content: currentContent.content },    // old content unchanged
  ],
  [validatedPath], // delete old path
  message
);

Confirmation that no content transformation happens: body.content is written verbatim to newPath; currentContent.content (just fetched from GitHub) is written verbatim to archivePath. Nothing reads or rewrites version strings; there is no scanner step today.

Natural attachment point for a version-string scanner (Piece 1)

Between line 152 (after currentContent is fetched) and line 161 (before githubMultiFileCommit is invoked). At that point all the inputs are available: body.content (the operator's new content), parsed.major and parsed.minor (the old version components), newParsed.major and newParsed.minor (the bumped version components), and the filename's base.

The scanner would: walk body.content looking for the patterns enumerated in V8; surface proposed substitutions to the operator for approval; return transformed content (and a side-by-side diff) to the editor for confirmation before the commit lands.

A second-layer attachment is in tools/static/editor.js:153-155 — the "Save as new version" click handler can intercept before posting, scan the content client-side, and present the diff inline; the server-side scanner remains the source of truth, but a client-side preview avoids a round-trip.

V8 Nuanced

Version-string patterns present in documents today

Multiple distinct patterns. The scanner needs to recognize each. Patterns enumerated below by example.

Pattern A — vN.M in <title> element

Example: architecture/loomworks-architecture-specification-v0_3.html:5

<title>Loomworks Architecture Specification — v0.3</title>

Also: operations/going-forward-operating-instructions-v0_3.html:5, scoping-notes/loomworks-phase-63-scoping-note-v0_1.html:5.

Pattern B — vN.M in document-meta / version-line element (early in document)

Example: architecture/loomworks-architecture-specification-v0_3.html:644

<div class="doc-version-line">v0.3<span class="sep">·</span>2026-05-21<span class="sep">·</span>Working draft</div>

Also: architecture/loomworks-architecture-specification-v0_3.html:616, operations/going-forward-operating-instructions-v0_3.html:71, scoping-notes/loomworks-phase-63-scoping-note-v0_1.html:270.

Pattern C — vN.M in footer (often stale; this is the live bug)

Example: architecture/loomworks-architecture-specification-v0_3.html:1677

<p>Loomworks Architecture Specification · v0.1 · 2026-05-21</p>

This is exactly the bug Phase 63 Piece 1 is named to fix. The filename, title, and version-line all say v0.3; the footer still says v0.1.

Pattern D — "from vN.M to vN.M+1" transition prose

Example from scoping note:

"...the editor was used to update the Loomworks Architecture Specification from v0.2 to v0.3 in preparation for substantive content work."

Example from operating instructions:

"This document supersedes v0.2. Earlier versions are preserved in archive/ for trajectory."

"What changed from v0.2"

This pattern is the most semantically fraught. References to historical predecessor versions ("we considered X in v0.1 but rejected it") are intentionally backward-looking and MUST NOT be auto-bumped. This is the strongest argument for the propose-and-approve flow rather than auto-substitute.

Pattern E — vN.M → vN.M / vN.M_M+1 / vN_M inside prose

"all versions are working drafts and increments are minor (v0.1 → v0.2 → v0.3). The transition to v1.0 ..."

"New documents start at v0_1..."

The underscore form (v0_1 / v0_2) appears in inline <code> tags, matching the filename convention. Document-meta version-lines use the dotted form (v0.1); prose mixes both.

Pattern F — Cross-document references with embedded versions

"lives in the methodology document (what-dunin7-is-building-v0_20.md currently, v0_21 candidate material in current-status-manifest)."

"(loomworks-architecture-specification-v0_2.html currently)."

This is a stale cross-reference — the live architecture spec is at v0.3, but the operating-instructions doc still names v0.2. A scanner that fires on cross-document references would need a way to know which references belong to the doc being bumped vs. unrelated docs.

Summary of patterns the scanner needs to recognize

Pattern Form Examples per file
AvN.M in <title>1 per doc
BvN.M in doc-meta / sidebar / version-line near top1-2 per doc
CvN.M in footer paragraph1 per doc
D"from vN.M to vN.M+1" / "supersedes vN.M" / "in vN.M we ..." proseseveral
Ev0.1 → v0.2 arrow notation and v0_1 / v0_2 underscore form in <code>several
FCross-document filename references e.g. name-v0_2.htmlmixed; excluded from bump

Critical design constraint surfaced: Patterns D, E, F are intentionally polysemic. Pattern D backward-references ("we considered X in v0.1") must NOT bump. Pattern F cross-references to OTHER documents must NOT bump. Patterns A, B, C are almost always safe to auto-substitute when the version under change matches the doc's own version. The scanner's UX must default to propose-not-mutate to handle the polysemic cases.

V9 Confirmed

The editor's current after-save behavior

After a successful version-bump save, the editor remains pointed at the now-archived predecessor: there is no breadcrumb update, no buffer reset, and no redirect to the new path.

  • File: /Users/dunin7/loomworks-record/tools/static/editor.js
  • Save response handler: saveDocument(payload) at lines 158-197.

The relevant response-handling code (lines 168-197)

const json = await res.json();
if (!res.ok) throw new Error(json.error || res.statusText);

if (payload.mode === "version-bump") {
  setStatus(
    `Saved as new version. Commit ${json.commitSha.slice(0, 8)}. ` +
      `New path: ${json.newPath}. Previous archived.`,
    "success"
  );
} else {
  setStatus(
    `Saved. Commit ${json.commitSha.slice(0, 8)}. ` +
      `The site will rebuild within a minute.`,
    "success"
  );
}

// Re-enable cancel only; the user closes the modal explicitly so they
// see the success message.
document.querySelectorAll(".editor-modal-actions button").forEach((b) => (b.disabled = true));
const cancelBtn = document.querySelector('[data-action="cancel"]');
if (cancelBtn) {
  cancelBtn.disabled = false;
  cancelBtn.textContent = "Close";
}

The status message names json.newPath (the new versioned path) and json.previousPath is also in the response (from save.ts:177), but neither is acted on:

  • No call to history.pushState(...) or location.assign(...).
  • No call to update the path local variable inside openEditFlow.
  • No call to refresh Monaco's content from the new path.
  • The breadcrumb (rendered by the chrome layer, not the editor JS) remains pointed at the predecessor.

The result: the Save button changes to a "Close" button, the success status is shown, the operator dismisses the modal — and the page they return to (the chrome) still shows them the v0.2 doc, while v0.3 has just been created on disk. The breadcrumb is generated by the static site build (tools/build_site.py), which won't re-render until the Cloudflare Pages build picks up the new commit minutes later.

Natural place an auto-reopen-at-new-path behavior would attach (Piece 2)

Inside saveDocument(...), after line 184 (the success setStatus call) and before line 187 (the button-disable block). At that point:

  • payload.mode === "version-bump" is known.
  • json.newPath is the new versioned path.
  • json.previousPath is the old path being archived.
  • The modal's local state (Monaco editor instance + path closure variable in the enclosing openEditFlow) is still live but no longer needed.

Action: close the existing modal and call openEditFlow(json.newPath) to reopen against the new versioned path. The reopened modal would do a fresh GET /api/document?path=... so it picks up the just-written content and sha. The operator's mental model — "I'm editing the document; the document is now at v0.3; I can keep editing it" — is preserved.

A secondary attachment point: the page's chrome can be told to update its breadcrumb via custom event or via history.replaceState, so even if the operator closes the modal, the URL-bar-visible path reflects the new version. This is a small extra: the auto-reopen above is the load-bearing fix.

V10 Contradicted

How the editor knows the operator's UUID today

The editor's authoring-identity surface is narrower than the brief's prose suggests.

What the brief's expected finding holds: yes, the Pages Function authenticates via Cloudflare Access (JWT presence-check), and Cf-Access-Authenticated-User-Email is currently the only identity hint the Function can read. Per Phase 62 Finding 3, that header is currently being read with a fallback default of editor@record.dunin7.com, which is what commits attribute to today.

What does NOT hold as written: the engine substrate does have get_person_by_email (src/loomworks/persons/registry.py:108-129), but there is no HTTP endpoint exposing it. Search confirmed: no router imports it; no path resolves email-to-UUID over HTTP. So the email-to-UUID resolution mechanism does not exist yet at the API surface — it would need to be built.

What identity info does the Pages Function actually have access to today

From functions/api/_shared.ts:124-129:

export function authenticatedEmail(request: Request): string {
  return (
    request.headers.get("Cf-Access-Authenticated-User-Email") ||
    "editor@record.dunin7.com"
  );
}
  • Cf-Access-Jwt-Assertion — present (the auth gate; _shared.ts:114), but the JWT is not decoded today (signature verification deferred per the comment at lines 102-112).
  • Cf-Access-Authenticated-User-Email — read with fallback per the snippet above.
  • That's it. No other Cloudflare Access identity headers are read.

Phase 62 Finding 3 says the email header is "currently not forwarded successfully." Reading the code: the fallback default IS being used today, so commits attribute to editor@record.dunin7.com. Per Phase 62 close notes (queued item), this is the symptom the brief names. Whether the underlying cause is Access-config or Pages-config is not investigated here — the symptom is what affects Phase 63.

Does any UUID resolution mechanism exist

  • At engine substrate level: yes — get_person_by_email(...) in loomworks.persons.registry.
  • At engine HTTP-API level: no — grep confirmed no router calls get_person_by_email, no route accepts email and returns a person UUID.

Cross-reference engine-side person table

  • Table column carrying email: persons.email. Email is NOT unique at the database layer (per docstring lines 113-118): "the substrate accepts multiple persons with the same email today (e.g. a shared mailbox or no email at all). This lookup returns the first match by created_at; callers that need uniqueness must enforce it themselves."
  • This is load-bearing for Phase 63: if the editor's authoring-identity flow looks up an email and gets back ONE of several persons sharing the email, the audit trail attributes to that person rather than the actual operator. Phase 63 Piece 3 has to either (a) accept this loose semantics, (b) constrain the lookup to a single-person-per-email subset, or (c) use a different identity primitive entirely (e.g. the JWT subject claim from Cloudflare Access, which is the per-Access-user identifier).

Endpoint cost for Phase 63 Piece 3

A new endpoint (e.g. POST /persons/resolve-email) would need to be built. Auth model is the open question — see V5 #4. The endpoint would be called from the Pages Function on every editor commit (or cached client-side per session). Pages Functions are stateless and would need to do this lookup each request unless an in-memory map is kept in the editor's static editor.js (which is reachable from the operator's browser, so caching there is reasonable).

Alternative path worth naming

The Cloudflare Access JWT carries a sub claim (the per-Access-user UUID) which is stable and unique per Access user. If Phase 63 verifies the JWT (the work the comment at _shared.ts:102-112 says was deferred), sub is available without an engine round-trip. The Access UUID isn't the engine's person UUID, so Phase 63 would still need a one-time mapping table from Access-sub to person-id — but that mapping changes only on signup, not on every request.

Summary on V10: The brief's text is roughly right but the gap is wider than its prose. There is no resolution endpoint today; both the endpoint AND a service-to-service auth path need to be designed for Piece 3.

V11 Confirmed

Confirmation that setting changes are the only narrative-event surface

Two call sites; no other narrative-event writers exist.

Search: grep -rn "write_setting_change_event\|AuditForayEventRow" /Users/dunin7/loomworks-engine/src/. Result: ten matches across five files.

  • audit/__init__.py:11,12,15,16 — re-exports.
  • audit/events.py:28,52,74,94 — definition + sole row constructor.
  • audit/models.py:8,28 — model definition.
  • orchestration/tune_setting.py:51,84 — Companion-spoken call site (V3 call site 1).
  • api/routers/me_settings.py:11,29,117 — button-clicked PUT call site (V3 call site 2).

No third file imports write_setting_change_event or constructs AuditForayEventRow directly. The orchestration _audit_setting_change helper (lines 65-99 of tune_setting.py) is the only wrapper, and it's only called from inside tune_setting.py (lines 489 + 596 — the enum and numeric dispatch paths).

The brief's expectation holds exactly: setting changes are the only narrative-event surface today. Phase 63 Piece 3 adds editor_commit as the second event-type value, and the writer pattern in V1 generalizes one-line straightforward to it.

Summary

Inspection character: mostly confirmed. Eight of eleven verifications return CONFIRMED with no caveats material to CR drafting (V1, V2, V3, V4, V6, V7, V9, V11). The audit substrate is structurally exactly as the brief expected; the writer pattern is uniform across both call sites; the table accepts new event types with no migration; no FORAY read endpoints exist; the version-bump path is content-preserving; the editor stays pointed at the predecessor; only one narrative-event surface exists today.

Two findings warrant attention before CR drafting

1. V5 (NUANCED) + V10 (CONTRADICTED in part) together identify a real Piece 3 scoping gap — there is no existing engine-side HTTP mechanism for the Pages Function to either (a) authenticate as a service or (b) resolve email→UUID, AND the production engine itself may not yet be live at api.loomworks.doneinseven.com / api.loomworks.dunin7.com. Phase 63 Piece 3 (audit-viewer + Pages-Function-to-engine path) cannot be scoped without naming:

  • The auth model for Pages-Function-to-engine calls. Three plausible paths: (a) shared service token in Cloudflare Pages secret + new bearer-style engine surface; (b) propagating the Cloudflare Access JWT and having the engine verify it; (c) running the audit reads from the operator's own browser with cookie auth (but this requires the operator to be engine-authenticated, which collapses two identity systems together).
  • The Access-email-to-person-UUID resolution mechanism. Whether to build the lookup endpoint, accept loose semantics (email non-unique), or pivot to a JWT-sub-based identity primitive.

2. V8 surfaces operational complexity for Piece 1 worth lifting into the CR: patterns D, E, F are intentionally polysemic — backward references and cross-document references must NOT be auto-bumped. The scoping note already says "propose-not-mutate"; the CR should make this explicit as an acceptance gate (e.g. "scanner surfaces N matches; operator approves each; intentional historical references can be skipped without aborting the bump").

Whether scoping note v0.2 is required before CR drafting opens

Yes — primarily to resolve the V5/V10 Piece 3 gap. The scoping note should name (or explicitly defer with rationale) the Pages-Function-to-engine auth model and the identity-resolution primitive. Without that decision, Piece 3's CR §8 (Test Plan) and §10 (Acceptance gates) cannot be specified. Piece 1 (V8) is a smaller polish — the propose-not-mutate principle can be captured in CR §A.4 (acceptance gates) without a scoping-note revision, but lifting it into v0.2 alongside V5/V10 would close the loop cleanly.

Halt-and-surface events during inspection

None. All eleven verifications completed within the brief's pacing target (~90 minutes effective). All named files existed at expected paths; no codebase drift was discovered. Inspection scope matched expectations.