DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/phase-50-companion-as-authority-and-public-form/phase-50-cr-companion-as-authority-and-public-form-v0_1.md

Phase 50 — Companion-as-Authority and Public Form — Change Request

Version. 0.1 Date. 2026-05-08 CR identifier. CR-2026-065 (verify against the registry at Step 0; advance to next available number if 065 is taken) Phase. 50 — Companion-as-Authority decision logic for grants + public credit-request form (engine half) + conversion-credit asset_id Operator override Repos affected. DUNIN7/loomworks-engine (substrate) and DUNIN7/loomworks (Operator Layer frontend) Tag at completion. phase-50-companion-as-authority-and-public-form on both repos Source documents. loomworks-phase-50-scoping-note-v0_2.md (authoritative scope; absorbs Step 0 findings) and phase-50-step-0-findings-v0_1.md (engine repo docs/phase-impl-notes/; verified live-codebase state). Drafting handoff: loomworks-phase-50-cr-drafting-handoff-v0_1.md.


1. Purpose

Phase 50 is the first delivery-class build instance of the Companion-proposes / Operator-commits pattern at the governance scale the methodology depends on. Phase 49 item 9 (reconciliation proposal applier) was the closed-loop counterpart: an engagement consuming its own observations and producing corrective action internally. Phase 50 item 2 is the inverse: the Credit Management Engagement consumes inbound observations (form submissions from prospective recipients), the Companion proposes a grant decision, the Operator approves through the Phase 45 ApprovalCard surface, and the engagement pipeline issues the grant via seam.issue_grant — the grant ships externally to a recipient whose email becomes a claim URL.

Three primary build items: (i) Companion-as-Authority decision logic for grant requests (item 2); (ii) public credit-request form endpoint, engine half (item 3); (iii) Operator override path for the conversion-credit asset_id (item 8). The marketing-site itself, SMTP automation for grant delivery, captcha, threshold-driven Companion autonomy, and the modify-the-proposal action on <GrantDecisionApprovalCard> are explicitly Phase 51+ or future. Items 10 (Phase 42 turns reconciler coverage) and 12 (reactivation in-session chrome) carry forward as deferred or conditional.

Phase 50 inherits Phase 49's two highest-priority methodology findings:

Phase 49 Finding 3's inline-emission discipline (closed-loop audit-trail integrity) does not directly apply: item 2 uses standard Phase 45 routes which already emit pipeline-instance events. The proposal lands as a held assertion in Credit Management Engagement Memory; approval flows through Phase 45's existing dispatch which already emits the right events; grant issuance through seam.issue_grant writes to credit.credit_grant with grant_kind='form_initiated'.

This CR is execution against loomworks-phase-50-scoping-note-v0_2.md. All decisions enumerated in §4 are settled; this CR specifies how, not whether.


2. Scope

2.1 In scope

  1. POST /authority/grant-request endpoint. Public unauthenticated; pydantic-validated body; on success creates a grant_request_received Memory event in the Credit Management Engagement via append_event. Routine Memory event — not a Phase 38 declare-and-register specification-grammar extension.
  1. slowapi rate-limit middleware. Add slowapi to pyproject.toml; wire as a FastAPI middleware; apply @limiter.limit("10/hour") (or comparable conservative rate; final value finalized by CC at Step 2 against alpha-load expectations) to the public endpoint.
  1. CORS allowlist extension. Env-var-driven (LOOMWORKS_CORS_ORIGINS); production deploys add the marketing-site origin (https://loomworks.com per deployment strategy v0.2). Dev/test posture unchanged.
  1. System-actor sentinel for form-submission events. Sentinel ActorRef(kind="contributor", contributor_id=<deterministic UUID>) per the _BOOTSTRAP_ACTOR precedent. Form payload (email, display_name, use_case, audience, source) lands in event payload.
  1. Companion-as-Authority decision dispatcher. Reads inbound grant_request_received events; reads supporting Memory (model profile assertions, eligibility heuristics); composes the decision prompt via the credit_voice loader; runs the LLM call; produces a GrantDecisionProposal held assertion with provenance pointing at the form-submission event.
  1. Phase 45 dispatch registration. register_action_dispatcher(..., delegation_required=True, on_decline=<callback>) — the Companion-mediated default, Finding 5 inheritance.
  1. <GrantDecisionApprovalCard> (Operator Layer). Sibling component to <ProposalApprovalCard>, both wrapping <NotificationCard> (composition, not subclass extension). Approve/Decline buttons; rationale, recipient email, credit_type, and amount displayed.
  1. Approval-triggered grant issuance. On Approve, the dispatcher calls seam.issue_grant(recipient_email=..., asset_id=..., amount=..., grant_kind='form_initiated', initiated_by=None, campaign_ref=..., metadata={...}). Decline path symmetric per Finding 5's on_decline callback (proposal state advances; no grant issues).
  1. read_credit_management_assertions parameterized. One-line refactor adding category: str = "model_profile" parameter; backward-compatible default preserves Phase 49 callers.
  1. _on_api_key_saved_for_conversion extended. Reads override via parameterized seam (category="conversion_credit_policy"); resolves most-specific-scope wins (referrer_id > campaign_ref > global); uses override.asset_id if matched, else falls through to default-mirror (Phase 48 P48-D2); records asset_id_source ("override" | "default_mirror") and override_assertion_id in flow metadata.
  1. prompts/credit_voice/grant_proposal.md voice template. Carries the same top-of-file AI-invisibility carve-out comment shape as Phase 49's three credit_voice templates (bounded named exception, scoped to physical-correspondence condition).

2.2 Out of scope (Phase 51+ or future)


3. Prerequisites

3.1 Baseline at Phase 50 Step 0

If the baseline diverges (test count off by more than ±2 routine noise, Alembic head different, working tree dirty, tag missing), CC stops at the start of Step 0 and reports before running any pre-flight items.

3.2 CR-drafting-time verifications already completed

The scoping note v0.2 absorbed eight verifications run by CC against the live codebase at Step 0 (recorded in phase-50-step-0-findings-v0_1.md). All resolved at scoping-note or CR-drafting scope; none triggered the substrate-friction-discipline-pattern (Finding 6 trajectory).

| # | Verification | Verdict | Where absorbed | |---|--------------|---------|---------------| | V1 | Phase 45 dispatch + ApprovalCard | HOLDS | scoping v0.2 §4.1 (sibling-composition); §5.3 | | V2 | Cross-engagement Memory read seam generalization | PARTIALLY HOLDS | scoping v0.2 §4.3, §5.1, §6 step 1 (one-line parameterization) | | V3 | Public endpoint posture | PARTIALLY HOLDS | scoping v0.2 §4.2 sub-steps; §5.1; §6 step 2 | | V4 | Memory event author for form submissions | PARTIALLY HOLDS | scoping v0.2 §3.6 (default shift); §4.2.3; §5.1 | | V5 | Grant-request event kind registration | HOLDS as routine event | scoping v0.2 §4.2.3, §5.1, §7 (Finding 2 boundary clarified) | | V6 | Conversion observer location and extension | HOLDS | scoping v0.2 §5.1 (carry; no draft-sketch revision) | | V7 | Voice composition module location | HOLDS | scoping v0.2 §5.2, §6 step 5 (path corrected) | | V8 | Rate-limit and CORS infrastructure | SIMPLE-VIABLE-FORM RECOMMENDED | scoping v0.2 §4.2.1, §4.2.2; §6 step 2 |

3.3 Pre-flight Step 0 (CR-time)

Before Step 1 begins, CC archives this CR at docs/phase-crs/phase-50-cr-companion-as-authority-and-public-form-v0_1.md and runs the following pre-flight reads. These are scoped to confirm specific live strings, signatures, and patterns that the CR specifies but did not read directly. The eight Step 0 verifications absorbed in v0.2 are not re-run; these are the CR-time deltas only.

  1. CR registry. Confirm CR-2026-065 is the next available CR number; advance if taken.
  2. register_action_dispatcher exact signature. Phase 49 extended it with delegation_required: bool = True and on_decline: Callable | None = None. Confirm full kwarg list and types; CR §12 specifies the dispatcher registration shape.
  3. process_approval and process_decline exact signatures. Confirm what arguments they receive and what they return; CR §13 specifies how <GrantDecisionApprovalCard> Approve/Decline buttons invoke them.
  4. <NotificationCard> props and composition shape. The Operator Layer component file. CR §13 specifies <GrantDecisionApprovalCard> composition.
  5. _BOOTSTRAP_ACTOR deterministic UUID generation pattern. The existing site that creates the sentinel ActorRef. CR §11 follows the same pattern for the form-submitter sentinel. Confirm the canonical identifier name (e.g., "form_submitter") is not already taken in the deterministic-UUID namespace.
  6. append_event signature and event_kind constraint. Confirm event_kind is String(64) and that the literal grant_request_received fits. CR §11 specifies the call shape.
  7. _on_api_key_saved_for_conversion exact location and signature. Confirm file path and function signature; CR §14 specifies the override-read insertion point.
  8. register_on_api_key_saved registration site. Confirm how the observer is registered (likely import-time side effect per Phase 48 §12.6); CR §14 confirms the existing pattern stays intact.
  9. CORS configuration mechanism. Confirm whether a config-env-var pattern exists or whether a new env-var name lands. CR §10 specifies the env-var name and allowlist extension shape.
  10. slowapi installation pattern + FastAPI app construction site. Confirm where the FastAPI app is constructed and where middleware is added. CR §10 specifies middleware wiring.
  11. read_credit_management_assertions exact signature. The function lives in loomworks/credit/cross_engagement_memory.py; confirm the current signature so the parameterization is a clean one-liner. CR §9 specifies the refactor.
  12. Voice loader at loomworks/orchestration/credit_voice.py. Confirm the module path and the loader function symbol (V7 absorbed the path; the loader function name is [CC verifies] since Phase 49 v0.3 §5.1 named load_credit_voice(name) against prompt_assets.py while v0.2 §5.2 names the module as credit_voice.py per V7). Phase 49 templates load through whichever symbol the live codebase uses; CR §15 specifies how grant_proposal.md integrates against the same symbol.
  13. Existing public endpoint pydantic patterns. Phase 47 / 48 public endpoints (POST /claim/grant, POST /signup). CR §10 mirrors the validation/error-response posture.
  14. Companion identity for Credit Management Engagement. Confirm whether CME has its own Companion identity for the dispatch surface. V4 surfaced that CME lacks a bound Operator; whether the CME Companion is a distinct entity for dispatch purposes is a related question worth confirming. CR §12 reflects whatever the live state is.
  15. Existing held-assertion authoring path. Confirm how Phase 16 contributions create held assertions; CR §12 specifies the proposal-authoring shape.

If any of items 2–15 reveal architectural divergence (not naming-only), CC halts and surfaces — does not draft and hope. Naming-only divergences resolve in-flight in the standard CC pre-flight discipline (Phase 47/48/49 precedent).

3.4 CR archival path

docs/phase-crs/phase-50-cr-companion-as-authority-and-public-form-v0_1.md in the engine repo (substrate-side archive; mirrors Phase 47/48/49). Archived at Step 0, before Step 1 begins.


4. Construction decisions this CR closes

Fourteen decisions transposed from loomworks-phase-50-scoping-note-v0_2.md (twelve substantive decisions plus two drafting-discipline decisions inherited from Phase 49 v0.3 precedent). These are settled at CR drafting time; CC executes against them, does not re-decide.

| ID | Decision | Where | |----|----------|-------| | P50-D1 | Phase 50 = items 2, 3 (engine half), 8. Items 6 and voice tuning continue as parallel Operator work; items 10 and 12 deferred. Marketing site itself, SMTP, captcha, modify-the-proposal action, threshold-driven autonomy, multi-Companion cross-checking are all out of Phase 50 (Phase 51+ or future). | scoping v0.2 §2; §5.5 | | P50-D2 | Item 2 alpha posture: always-require-approval. Companion proposes; Operator commits via Phase 45 ApprovalCard surface; auto-issue gated by future config flag. delegation_required=True is the dispatch default — Phase 49 Finding 5 inherits cleanly. | scoping v0.2 §4.1; §12.1 | | P50-D3 | System-actor sentinel for form-submission Memory events. Sentinel ActorRef(kind="contributor", contributor_id=<deterministic UUID>) per the _BOOTSTRAP_ACTOR precedent. Form details (email, display_name, use_case, audience, source) land in event payload. Origin attribution lives in payload, not actor. The methodology question of whether external/anonymous form submitters warrant their own ActorRef kind is not raised by this absorption — the sentinel pattern handles the case cleanly without a new kind. Discovery-trajectory note: v0.1 sketched Companion-as-receiver as the default; V4 surfaced structural mismatch (CME has no bound Operator; kind="companion" requires a Companion-Operator association that does not exist for CME; Companion-as-receiver miscommunicates origin). v0.2 settles on system-actor sentinel — the v0.1 default is the alternative considered and set aside. | scoping v0.2 §3.6; §4.2.3; §12.2 | | P50-D4 | grant_request_received is a routine Memory event via append_event. No Phase 38 declare-and-register treatment. No DST/DRT events. No lifespan hook. The event_kind field is free-form String(64); grant_request_received lands directly. Reusable distinction: Phase 49 Finding 2 (engagement specification grammar evolves through Memory) governs cases where the engagement adds new DeclaredShapeTypes / DeclaredRenderTypes — routine Memory events do not invoke declare-and-register. | scoping v0.2 §4.2.3; §5.1; §7 (Finding 2 boundary) | | P50-D5 | slowapi rate-limit middleware. Add slowapi to pyproject.toml; wire as FastAPI middleware; apply @limiter.limit("10/hour") (or comparable; CC finalizes the limit value at Step 2 against alpha-load expectations) to POST /authority/grant-request. Clean upgrade path to redis-backed limiter when production load warrants — the in-memory bucket alternative would need replacement, not scaling. Alternative considered and set aside: in-memory bucket implementation (~35-50 LOC vs slowapi's ~5 LOC + 1 dep; in-memory bucket is migration-only, no scaling path). | scoping v0.2 §4.2.1; §12.6 | | P50-D6 | CORS allowlist extension via env var. LOOMWORKS_CORS_ORIGINS (or whatever the canonical name is — [CC verifies] against any existing config-env-var naming convention; if a loomworks_cors_origins config field exists, follow that). Production deploys add the marketing-site origin (https://loomworks.com per deployment strategy v0.2). Dev/test continues with the existing permissive posture. | scoping v0.2 §4.2.2 | | P50-D7 | Parameterize read_credit_management_assertions(category: str = "model_profile"). One-line refactor with backward-compatible default. Item 8 calls with category="conversion_credit_policy". No sibling function. Phase 49's model_profile callers continue to work without modification. Alternative considered and set aside: sibling function (read_conversion_credit_policy_assertions) — duplicates filter-path logic, accumulates maintenance burden, contradicts the OVA-stub-style alpha authorizer pattern of a single read-seam surface. | scoping v0.2 §4.3; §5.1 | | P50-D8 | Override resolution rule for item 8: most-specific-scope wins. Order: referrer_id-scoped > campaign_ref-scoped > global. Resolution lives at the caller (the conversion observer), not in the read seam. Alternative considered and set aside: explicit precedence table in the read seam — premature; defer until a third use-case demonstrates the table's necessity. | scoping v0.2 §4.3; §12.3 | | P50-D9 | <GrantDecisionApprovalCard> is a sibling component to <ProposalApprovalCard>. Both wrap <NotificationCard> (composition pattern). No subclass extension. Component scaffolding (notification metadata read, Approve/Decline button wiring, dispatch invocation) shared via <NotificationCard>. Discovery-trajectory note: v0.1 sketched <GrantDecisionApprovalCard> as a subclass extension of <ProposalApprovalCard>; V1 confirmed actual pattern is sibling composition — both wrap <NotificationCard>. The v0.1 sketch is the alternative considered and set aside. | scoping v0.2 §4.1; §5.3 | | P50-D10 | Voice loader path: loomworks/orchestration/credit_voice.py (V7 absorption — corrected from v0.1's tentative loomworks/credit/companion_intelligence/voice_composition.py). Item 2's prompts/credit_voice/grant_proposal.md template loads through the same loader used by Phase 49's three credit_voice templates. The exact loader function symbol is [CC verifies] against the live codebase. | scoping v0.2 §5.2; §6 step 5 | | P50-D11 | AI-invisibility carve-out. prompts/credit_voice/grant_proposal.md carries the same top-of-file carve-out comment shape as Phase 49's three templates. The grant-decision rationale may name credit tiers (haiku / sonnet / opus) — that's what the proposal is — and the carve-out names this as bounded named exception, scoped to the physical-correspondence condition. Phase 49 Finding 1 inheritance. | scoping v0.2 §7; handoff §2.11 | | P50-D12 | Audit-trail integrity. Item 2 uses standard Phase 45 routes, so Phase 49 Finding 3's inline-emission discipline does not directly apply. The proposal lands as a held assertion in Credit Management Engagement Memory with provenance pointing at the form-submission Memory event; approval flows through Phase 45's existing dispatch which already emits the right events; grant issuance through seam.issue_grant writes to credit.credit_grant with grant_kind='form_initiated'. Reusable distinction: Finding 3 governs closed-loop class engagements that bypass standard pipeline routes; closed-loop engagements that use standard routes inherit pipeline-instance event emission for free. | scoping v0.2 §7; handoff §2.12 | | P50-D13 | Reserved-slot-not-skipped semantics carry forward. Build slots 6, 7, 8 read as (reserved — buffer for amendments arising from steps 1–5). Reserved-not-skipped: unconsumed if no amendment arises; the buffer's value is its presence, not its consumption. Phase 49 v0.3 §15 precedent; manifest v0.34 reserved-slot principle. | Phase 49 v0.3 §15 precedent; handoff §5 drafting discipline | | P50-D14 | Voice iteration framing. §15 acceptance gates verify "the voice fires"; quality emerges through Operator iteration after wire-up. Iteration that lands after Phase 50 tag is ongoing Operator work, not a Phase 50 amendment trigger. Voice content authored during the build is the as-shipped baseline; subsequent refinement has its own track (likely a methodology consolidation pass when persona has emerged across enough surfaces). Phase 49 v0.3 P49-D15 precedent (T1 amendment); applies to all voice-shipping phases. | Phase 49 v0.3 P49-D15 / T1 precedent |


5. Migration

No Migration 0065 anticipated.

V6 (Step 0 finding, absorbed in scoping v0.2 §5.1 / §8) confirmed that the conversion observer's structure accommodates the metadata extension cleanly: _on_api_key_saved_for_conversion writes to credit.foray_action_flows.metadata (JSONB), and adding the keys asset_id_source and override_assertion_id does not require a column or schema change. The metadata-key approach matches the existing FORAY-metadata pattern (Phase 47 §10.1; Phase 48 §12.4).

Documented fallback. If CC's pre-flight at Step 1 reveals a performance concern on the new metadata key (e.g., the override-resolution observer queries cause a regression on conversion-flow inserts at alpha volume), Migration 0065 lands as a small partial-index revision:


CREATE INDEX idx_conversion_asset_id_source
  ON credit.foray_action_flows ((metadata->>'asset_id_source'))
  WHERE metadata->>'reason' = 'conversion_detected';

This is a single-revision migration with no data backfill (override applies forward only per scoping v0.2 §4.3). Default posture: do not add the index unless pre-flight or initial implementation reveals a performance concern.

If neither pre-flight nor initial implementation surfaces such a concern, Phase 50 ships with no migration and Alembic stays at head 0064.


6. New backend modules and changes

Per scoping v0.2 §5.1. Substrate-side surface, organized by module.

6.1 New modules

6.2 Existing modules amended

6.3 No changes to existing tables

Phase 50 does not alter any existing table. The credit.* tables (Phase 47), system_config (existing), and persons lifecycle columns (Phase 47/49) are untouched.

6.4 Sentinel ActorRef wiring

Per scoping v0.2 §3.6 / §4.2.3 / P50-D3, the form-submitter sentinel ActorRef is constructed once at module import time (mirroring _BOOTSTRAP_ACTOR's pattern) and reused across all form submissions. The deterministic UUID ensures that all grant_request_received events authored across all form submissions carry the same contributor_id — making "show me all form-initiated grant requests" an indexed query on event.actor.contributor_id rather than a payload-key scan. (CR §11.)


7. New backend prompts and content

Per scoping v0.2 §5.2.

7.1 New voice template

The template's first-iteration content is authored by CC at Step 5 with structural acceptance criteria (the voice fires; AI-invisibility carve-out comment is present at top of file; loader resolves the template; the rendered output reaches the <GrantDecisionApprovalCard> rationale field). Quality emerges through Operator iteration after wire-up per P50-D14 — iteration after the Phase 50 tag is ongoing Operator work, not a Phase 50 amendment trigger.

7.2 AI-invisibility carve-out comment

Per P50-D11. The top-of-file comment carries the same shape as Phase 49's three credit_voice templates (tier_drop.md, near_exhaustion.md, exhaustion_choice.md):


<!-- AI-invisibility carve-out (Phase 49 Finding 1; bounded named exception):
     This template MAY name credit tiers (haiku / sonnet / opus) explicitly.
     The carve-out is scoped to the physical-correspondence condition: credits
     have substrate-physical reality the Operator can verify against the bank
     balance. No general AI-invisibility relaxation is implied; the carve-out
     applies to credit-system surfaces only. -->

Exact language [CC verifies] against Phase 49's templates; CC mirrors the existing comment shape rather than inventing fresh prose.

7.3 New decision prompt template (substrate-internal)

Initial draft content: per scoping v0.1 §4.1 — the prompt instructs the Companion to read the form payload, the model profile assertions, and any campaign / referral / eligibility context from Memory; weigh against eligibility heuristics; produce a GrantDecisionProposal with credit_type, amount, recipient_email, rationale, and provenance.


8. Frontend changes (Operator Layer)

Per scoping v0.2 §5.3. Substantively narrow: one new component, no new top-level routes.

8.1 New component

8.2 Composition shape

Per P50-D9 and <NotificationCard>'s data-driven rendering pattern (Phase 46 §13.1):

8.3 No new routes

The approval surface mounts in the existing Phase 44/45 notification drawer / approval surface. The Operator sees grant proposals alongside Phase 49 reconciliation proposals — the two are sibling notification kinds, both rendered by the same <NotificationCard> machinery, distinguished by their respective sibling Approval-Card components.

8.4 Test posture

Vitest tests verify:

Estimated 10–12 frontend tests (per scoping v0.2 §6.3).


9. Cross-engagement Memory read seam parameterization

Item 8 prerequisite. This section lands at Step 1 alongside item 8.

9.1 Current shape (per V2)

Phase 49 shipped read_credit_management_assertions in loomworks/credit/cross_engagement_memory.py with the category filter as a hardcoded constant ('model_profile'). The filter path uses payload->'metadata'->>'category' = 'model_profile' (the corrected filter path from Phase 49 pre-flight per manifest v0.35 entry 91).

9.2 Refactor

Add category: str = "model_profile" as a kwarg with backward-compatible default. Replace the hardcoded constant in the filter expression with the parameter:


async def read_credit_management_assertions(
    *,
    category: str = "model_profile",
    db: AsyncSession,
) -> list[Assertion]:
    """Cross-engagement read of Credit Management Engagement assertions
    by metadata category. OVA-stub-style alpha authorizer (Phase 49 seam).
    
    Default category preserves Phase 49 callers; Phase 50 callers pass
    category='conversion_credit_policy' for item 8.
    """
    # Existing filter, now parameterized:
    # ...payload->'metadata'->>'category' = :category...

Exact signature, async-vs-sync posture, and parameter name [CC verifies] against the live function — the example above is illustrative.

9.3 Backward compatibility

Phase 49's model_profile callers continue to work without modification — the default kwarg covers them. Acceptance gate: Phase 49 callers' tests pass unchanged after the refactor.

9.4 Tests


10. Public endpoint posture: slowapi + CORS sub-steps

Per scoping v0.2 §4.2.1, §4.2.2. Lands at Step 2.

10.1 slowapi rate-limit middleware

Dependency. Add slowapi to pyproject.toml. Install via pip install -e .[dev] or comparable repo posture. Confirm Python version compatibility ([CC verifies] against the engine's current Python pin).

Wiring. At the FastAPI app construction site ([CC verifies] exact module path):


from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)

The _rate_limit_exceeded_handler returns a 429 with the standard slowapi headers (Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset).

Decorator on the public endpoint. POST /authority/grant-request carries @limiter.limit("10/hour") (or comparable conservative rate; CC finalizes the limit value at Step 2 against alpha-load expectations). Exempt paths: existing authenticated routes carry no decorator and are not rate-limited; only the public unauthenticated endpoint adds the decorator.

Upgrade path. When production load warrants, swap the in-memory bucket (slowapi default) for a redis-backed limiter via slowapi's storage option. The app-level Limiter instance constructor changes; nothing else does.

10.2 CORS allowlist extension

Env var. LOOMWORKS_CORS_ORIGINS (name [CC verifies] against any existing config-env-var naming convention; if loomworks_cors_origins config field exists, follow that). Comma-separated list of origins.

Wiring. At the FastAPI app construction site, the CORS middleware reads the env var:


import os
from fastapi.middleware.cors import CORSMiddleware

cors_origins_raw = os.getenv("LOOMWORKS_CORS_ORIGINS", "")
cors_origins = [o.strip() for o in cors_origins_raw.split(",") if o.strip()]

if cors_origins:
    # Production posture: explicit allowlist
    app.add_middleware(
        CORSMiddleware,
        allow_origins=cors_origins,
        allow_credentials=True,
        allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
        allow_headers=["*"],
    )
else:
    # Dev/test posture: existing permissive behavior unchanged
    # (preserve current CORS middleware registration, if any)
    ...

Exact integration [CC verifies] against current CORS posture. Production deploys set LOOMWORKS_CORS_ORIGINS=https://loomworks.com (per deployment strategy v0.2). Dev/test deploys leave the var unset; existing permissive behavior persists.

10.3 Endpoint posture (request/response shape)

POST /authority/grant-request

Request body (pydantic-validated, strict types per Phase 47/48 precedent):


{
  "email": "<email format>",
  "display_name": "<string, 1-128 chars>",
  "use_case": "<string, 1-2000 chars>",
  "audience": "<string, 1-128 chars; optional>",
  "source": "<string, 1-128 chars; optional, e.g. 'marketing-site' | 'conference' | 'referral'>"
}

Response 202 Accepted (per public-endpoint asynchronous-acknowledgment pattern):


{
  "status": "received",
  "message": "Your request has been received. The Authority will respond by email."
}

Response 422: pydantic validation failure (standard FastAPI shape). Response 429: slowapi rate-limit exceeded (slowapi-standard shape).

No PII echoed in the response body — the response is intentionally generic.

10.4 Tests

Per scoping v0.2 §6.1 step 2 (~21–26 tests):


11. System-actor sentinel for form-submission events

Per scoping v0.2 §3.6 / §4.2.3 / P50-D3. Lands at Step 2 alongside the public endpoint.

11.1 Sentinel construction

Following _BOOTSTRAP_ACTOR's pattern ([CC verifies] exact construction site):


import uuid

# Deterministic UUID for the form-submitter sentinel
_FORM_SUBMITTER_NAMESPACE = uuid.UUID("...") # NAMESPACE_OID or similar
_FORM_SUBMITTER_UUID = uuid.uuid5(_FORM_SUBMITTER_NAMESPACE, "form_submitter")

_FORM_SUBMITTER_ACTOR = ActorRef(
    kind="contributor",
    contributor_id=_FORM_SUBMITTER_UUID,
)

The exact UUID generation follows the _BOOTSTRAP_ACTOR precedent — CC reads the existing site at Step 2 Step 0 inspection and mirrors the namespace + identifier shape. The canonical identifier name is "form_submitter"; if this name is already taken in the deterministic-UUID namespace, CC halts and surfaces.

11.2 Origin attribution

Form details land in event payload, not in the actor:


await append_event(
    engagement_id=credit_management_engagement_id,
    actor=_FORM_SUBMITTER_ACTOR,
    event_kind="grant_request_received",
    payload={
        "email": req.email,
        "display_name": req.display_name,
        "use_case": req.use_case,
        "audience": req.audience,
        "source": req.source,
        # No PII echoing back to actor; submitter identity lives in payload.
    },
    db=db,
)

11.3 Why not a new ActorRef kind

The methodology question of whether external/anonymous form submitters warrant their own ActorRef kind is not raised by this absorption. The sentinel pattern handles the case cleanly without a new kind. If a future case requires distinguishing external submitter identity (signed form submissions, OAuth-authenticated requesters, multi-tenant marketing sites), the question reopens at that point. Phase 50 records this trajectory in implementation notes for the next manifest consolidation pass.

11.4 Tests


12. Companion-as-Authority decision dispatcher

Per scoping v0.2 §4.1. The substantive substrate section. Lands at Steps 3–4.

12.1 What this is

The Companion-as-Authority dispatcher is the substrate piece that turns inbound grant_request_received Memory events into proposed grant decisions held for Operator approval. The flow:

  1. Trigger. A grant_request_received Memory event lands in Credit Management Engagement Memory (via §10/§11 above).
  2. Memory reads. The dispatcher reads the form payload, the model profile assertions (category="model_profile"), and any campaign / referral / eligibility-heuristic assertions in CME Memory.
  3. Voice composition. The decision prompt (per §7.3) is composed with the form payload + Memory reads as context.
  4. LLM call. A single LLM call produces the proposal: credit_type, amount, recipient_email, rationale.
  5. Held assertion. The proposal lands as a GrantDecisionProposal held assertion in CME Memory with provenance pointing at the form-submission event.
  6. Notification. A companion_notifications row is created with action_type='grant_decision_proposal' (or comparable; [CC verifies] against existing notification action_type values from Phase 49 reconciliation precedent). The Operator sees <GrantDecisionApprovalCard> in the notification drawer / inbox.
  7. Approval. On Approve (POST /operator/notifications/{id}/approve), the dispatcher's process_approval callback invokes seam.issue_grant(...) and the grant ships.
  8. Decline. On Decline, the on_decline callback advances the proposal state to declined; no grant issues.

12.2 Dispatch registration

Per P50-D2 (always-require-approval) and Finding 5 (bimodal dispatch — Companion-mediated default):


from loomworks.phase_45.dispatch import register_action_dispatcher

def _process_grant_proposal_approval(notification_id, db) -> ApprovalResult:
    """Approve callback: load the held assertion, extract proposal fields,
    invoke seam.issue_grant, advance proposal state to 'issued'."""
    ...

def _process_grant_proposal_decline(notification_id, db) -> DeclineResult:
    """Decline callback: advance proposal state to 'declined'; no grant issues."""
    ...

register_action_dispatcher(
    action_type="grant_decision_proposal",  # or comparable; [CC verifies] naming convention
    process_approval=_process_grant_proposal_approval,
    on_decline=_process_grant_proposal_decline,
    delegation_required=True,  # Companion-mediated default
)

Exact signatures [CC verifies] against the live register_action_dispatcher, process_approval, process_decline shapes (per Phase 50 Step 0 inspection items 2 and 3).

12.3 Voice composition seam

Per Phase 49 Finding 5.1 (voice composition is one-LLM-call away from the converse pipeline). The dispatcher composes the decision prompt before the LLM call:


from loomworks.orchestration.credit_voice import load_credit_voice  # path per P50-D10; symbol [CC verifies]

async def _propose_grant_decision(
    *,
    form_event: MemoryEvent,
    db: AsyncSession,
) -> GrantDecisionProposal:
    """Compose the decision prompt; call the LLM; produce the proposal."""
    # Memory reads
    model_profiles = await read_credit_management_assertions(category="model_profile", db=db)
    eligibility_context = await read_credit_management_assertions(category="eligibility_heuristic", db=db)
    # ... (other category reads as relevant)
    
    # Voice composition
    decision_prompt_template = await load_credit_voice("grant_decision_prompt")  # symbol [CC verifies]
    decision_prompt = compose_decision_prompt(
        template=decision_prompt_template,
        form_payload=form_event.payload,
        model_profiles=model_profiles,
        eligibility_context=eligibility_context,
    )
    
    # LLM call
    proposal_raw = await llm_call(prompt=decision_prompt, ...)
    
    # Render rationale via voice template
    rationale_template = await load_credit_voice("grant_proposal")  # symbol [CC verifies]
    rationale = render_rationale(template=rationale_template, decision=proposal_raw, ...)
    
    return GrantDecisionProposal(
        recipient_email=form_event.payload["email"],
        credit_type=proposal_raw.credit_type,
        amount=proposal_raw.amount,
        rationale=rationale,
        provenance={"source_event_id": form_event.id},
    )

The structure above is illustrative; CC verifies at Step 3 against Phase 49's actual voice-composition implementation (which template loader, which LLM-call wrapper, which response model) and mirrors that shape rather than inventing a parallel one.

12.4 Held-assertion authoring

The proposal lands as a held assertion in CME Memory. CC reads the existing held-assertion authoring path (Phase 16 contributions; pre-flight item 15) and mirrors that shape. The held assertion carries:

12.5 Approval path — seam.issue_grant invocation

Per P50-D12 audit-trail integrity:


def _process_grant_proposal_approval(notification_id, db) -> ApprovalResult:
    notification = load_notification(notification_id, db)
    proposal = load_held_assertion(notification.assertion_id, db)
    
    seam = get_credit_seam()
    grant = seam.issue_grant(
        recipient_email=proposal.recipient_email,
        asset_id=proposal.credit_type,           # e.g. 'loomworks_credit_haiku'
        amount=proposal.amount,
        grant_kind="form_initiated",
        initiated_by=None,                        # form-initiated; no Operator initiator
        campaign_ref=proposal.campaign_ref,      # if present in payload
        metadata={
            "source_event_id": str(proposal.provenance["source_event_id"]),
            "approval_notification_id": str(notification_id),
            # provenance chain: form event → proposal → grant → claim URL
        },
    )
    
    # Advance proposal state to 'issued'
    proposal.state = "issued"
    db.add(proposal)
    
    return ApprovalResult(success=True, grant_id=grant.id, claim_url=grant.claim_url)

seam.issue_grant's exact signature [CC verifies] against Phase 47 §10.1; the call shape above mirrors that signature with grant_kind='form_initiated' per P50-D12.

12.6 Decline path — on_decline callback


def _process_grant_proposal_decline(notification_id, db) -> DeclineResult:
    notification = load_notification(notification_id, db)
    proposal = load_held_assertion(notification.assertion_id, db)
    
    proposal.state = "declined"
    db.add(proposal)
    
    return DeclineResult(success=True)

Symmetric with the approval path. No grant issues; no seam call. The proposal state advances to declined for audit.

12.7 Idempotency and re-entrancy

12.8 Tests

Per scoping v0.2 §6.1 step 3 (~15–20 tests for sub-piece A; ~10–15 backend tests for sub-piece B):

Sub-piece A — dispatcher substrate (Step 3):

Sub-piece B — approval surface and dispatch (Step 4):

12.9 Halt conditions specific to §12


13. GrantDecisionApprovalCard

Per scoping v0.2 §4.1 / §5.3 / P50-D9. Frontend component; lands at Step 4 (sub-piece B).

13.1 Composition pattern

<GrantDecisionApprovalCard> and <ProposalApprovalCard> are siblings, both wrapping <NotificationCard>. Per <NotificationCard>'s data-driven rendering (Phase 46 §13.1):


import { NotificationCard } from "@/components/notifications/NotificationCard";

interface GrantDecisionApprovalCardProps {
  notification: Notification;  // existing Phase 44/45 type; [CC verifies] exact shape
  onApprove: () => void;
  onDecline: () => void;
}

export function GrantDecisionApprovalCard({
  notification,
  onApprove,
  onDecline,
}: GrantDecisionApprovalCardProps) {
  const proposal = notification.payload as GrantDecisionProposalPayload;
  
  return (
    <NotificationCard
      title={`Grant proposal: ${proposal.recipient_email}`}
      body={proposal.rationale}
      timestamp={notification.created_at}
      approvalStatus={notification.approval_status}
      onApprove={onApprove}
      onDecline={onDecline}
      // ... other NotificationCard props as needed
    />
  );
}

The card displays:

Exact prop shape and rendering details [CC verifies] against <ProposalApprovalCard>'s implementation — CC mirrors that pattern rather than inventing a parallel one.

13.2 Discoverable distinguisher

<NotificationCard> is the data-driven base; sibling cards (<ProposalApprovalCard>, <GrantDecisionApprovalCard>) discriminate by the notification's action_type. The dispatcher / drawer / inbox surface routes incoming notifications to the appropriate sibling card by inspecting action_type. Routing logic [CC verifies] against existing Phase 45 / 46 routing.

13.3 No new top-level routes

Per scoping v0.2 §5.3. The card surfaces in the existing notification drawer (/inbox or comparable) and the side-panel notification drawer. No new routes; no changes to src/app/layout.tsx.

13.4 Tests

Per scoping v0.2 §6.3 (~10–12 frontend vitest tests, distributed across §8 and here):


14. Conversion observer extension for asset_id override

Per scoping v0.2 §4.3. Lands at Step 1.

14.1 Existing observer (per V6)

_on_api_key_saved_for_conversion (Phase 48 §12.3) writes a referral credit flow when a referred person saves their own API key. The asset_id default-mirrors the referee's claimed grant per P48-D2 (haiku referee → haiku conversion credit; sonnet → sonnet; opus → opus).

V6 confirmed the observer's structure accommodates the override-read insertion cleanly. Module location, signature, and register_on_api_key_saved registration call site [CC verifies] per pre-flight items 7 and 8.

14.2 Override resolution

Most-specific-scope wins per P50-D8: referrer_id-scoped > campaign_ref-scoped > global. Resolution lives at the caller (the conversion observer), not in the read seam (per P50-D7).


async def _resolve_conversion_credit_override(
    *,
    referrer_id: UUID,
    campaign_ref: str | None,
    db: AsyncSession,
) -> ConversionCreditOverride | None:
    """Read conversion-credit-policy override assertions; resolve most-specific-scope.
    
    Order:
      1. Override scoped to specific referrer_id matching this referrer.
      2. Override scoped to specific campaign_ref matching the grant's campaign_ref.
      3. Override with scope='global'.
      4. None — fall through to default-mirror.
    """
    overrides = await read_credit_management_assertions(
        category="conversion_credit_policy",
        db=db,
    )
    
    # Filter to currently-effective overrides (effective_from <= now < expires_at or expires_at is null)
    now = datetime.now(timezone.utc)
    active = [o for o in overrides if _is_active(o, now)]
    
    # Most-specific-scope wins
    by_referrer = [o for o in active if o.scope.get("referrer_id") == str(referrer_id)]
    if by_referrer:
        return _to_override(by_referrer[0])
    
    if campaign_ref:
        by_campaign = [o for o in active if o.scope.get("campaign_ref") == campaign_ref]
        if by_campaign:
            return _to_override(by_campaign[0])
    
    by_global = [o for o in active if o.scope == "global"]
    if by_global:
        return _to_override(by_global[0])
    
    return None

Exact pydantic / schema shapes [CC verifies] against the existing assertion-read contract.

14.3 Observer extension


async def _on_api_key_saved_for_conversion(person_id, scope, db) -> None:
    if scope != "operator":
        return
    
    person = await db.get(PersonRow, person_id)
    if person is None or person.referred_by is None:
        return
    
    if await conversion_credit_already_written(db, person_id):
        return
    
    grant = await get_most_recent_claimed_grant(db, person_id)
    if grant is None:
        logger.warning(...)
        return
    
    referral_amount = await read_system_config_int("referral_credit_amount", default=10000)
    
    # NEW for Phase 50 item 8:
    override = await _resolve_conversion_credit_override(
        referrer_id=person.referred_by,
        campaign_ref=grant.campaign_ref,
        db=db,
    )
    asset_id = override.asset_id if override else grant.asset_id
    
    seam = get_credit_seam()
    await seam.write_referral_credit_flow(
        referrer_person_id=person.referred_by,
        asset_id=asset_id,
        amount=referral_amount,
        metadata={
            "reason": "conversion_detected",
            "converted_person_id": str(person_id),
            "source_grant_id": str(grant.id),
            # NEW for Phase 50 item 8:
            "asset_id_source": "override" if override else "default_mirror",
            "override_assertion_id": str(override.assertion_id) if override else None,
        },
    )

14.4 Override assertion shape (initial draft per scoping v0.1 §4.3)


{
  "category": "conversion_credit_policy",
  "scope": "global" | { "campaign_ref": "..." } | { "referrer_id": "..." },
  "override_asset_id": "loomworks_credit_haiku" | "loomworks_credit_sonnet" | "loomworks_credit_opus",
  "rationale": "...",
  "effective_from": "<ISO timestamp>",
  "expires_at": "<ISO timestamp>" | null
}

The Operator authors override assertions in CME Memory through standard contribution channels (Phase 16 / Phase 29 lifecycle). Phase 50 does not ship Operator-side authoring chrome for this category — the override path is substrate availability, not an Operator-Layer feature in Phase 50 scope.

14.5 Idempotency and backfill

14.6 Observability

The conversion flow's metadata now records asset_id_source ("override" | "default_mirror") and (if applicable) the override_assertion_id of the override that fired. The Operator can audit "why did this referrer get sonnet not haiku" by reading the conversion flow's metadata.

14.7 Tests

Per scoping v0.2 §6.1 step 1 (~14–17 tests):


15. Voice template authoring

Per scoping v0.2 §5.2 / P50-D10 / P50-D11 / P50-D14. Lands at Step 5.

15.1 What lands

15.2 Voice register

Per loomworks-companion-expertise-note-v0_1.md and Phase 49's voice posture: warm, considered, plain-English; Operator-vocabulary throughout; no model names, no AI-disclosure language. The grant_proposal voice may name credit tiers (haiku / sonnet / opus) per the AI-invisibility carve-out (P50-D11).

15.3 AI-invisibility carve-out comment

The top-of-file carve-out comment carries the Phase 49 shape (per §7.2 above). Exact language [CC verifies] against Phase 49's templates; CC mirrors the existing comment shape rather than inventing fresh prose.

15.4 First-iteration content

CC authors first-iteration content at Step 5 with structural acceptance criteria only (per P50-D14):

Voice quality is materially below the Operator's bar at first iteration. This is not a halt trigger — voice quality emerges through Operator iteration per P50-D14. CC ships first-draft voice content; Operator refines after wire-up. Iteration that lands after Phase 50 tag is ongoing Operator work, not a Phase 50 amendment trigger. Voice content authored during the build is the as-shipped baseline; subsequent refinement has its own discipline (likely a methodology consolidation pass when persona has emerged across enough surfaces).

15.5 Tests

Per scoping v0.2 §6.1 step 5 (~5–8 tests):


16. Build steps

Eight slots: 5 active + 3 reserved buffer per P50-D13. Two checkpoints (A and B). Inverted-pyramid order: smallest and most independent first; foundations next; dependents on top.

Step 1 — Item 8: Operator override for conversion asset_id

What. Parameterize read_credit_management_assertions (one-line refactor; backward-compatible default). Add the override resolver. Extend _on_api_key_saved_for_conversion to read overrides and record metadata. Smallest, independent slice; lands first to validate the pre-flight assumptions without touching the larger surface.

Step 0 inspection. Before implementing, CC reads:

Build. Per §9 (parameterization) and §14 (override resolver + observer extension). Includes the override-resolution helper (§14.2) and the observer's metadata extension (§14.3).

Tests. Per §9.4 (~5 tests) + §14.7 (~14–17 tests). Total ~14–17 tests for Step 1 (the §9 tests overlap with §14's).

Acceptance gate. All Step 1 tests pass; Phase 49's existing model_profile callers' tests pass unchanged; no regression in the Phase 48 conversion-detection baseline tests.

Halt conditions.

Step 2 — Item 3 engine half: POST /authority/grant-request + slowapi + CORS extension

What. New public unauthenticated endpoint with pydantic validation, system-actor sentinel Memory event creation, slowapi rate-limit middleware wired, CORS env-var-driven allowlist.

Step 0 inspection. Before implementing, CC reads:

Build. Per §10 (slowapi + CORS) and §11 (sentinel + event creation). Endpoint at loomworks/api/routes/authority.py.

Tests. Per §10.4 (~9 happy-path / validation / rate-limit / CORS tests) + §11.4 (~5 sentinel tests) + integration tests for the end-to-end flow (form post → Memory event lands). Total ~21–26 tests for Step 2.

Acceptance gate. All Step 2 tests pass; no regression on existing public endpoints; rate-limit decorator does not affect authenticated routes; CORS dev/test posture unchanged when LOOMWORKS_CORS_ORIGINS is unset.

Halt conditions.


Checkpoint A — between Step 2 and Step 3

CC pauses for Operator review. The two independent slices (item 8, item 3 engine half) are complete; the foundation for item 2 (the substantive section) is in place. Operator reviews implementation notes draft (phase-50-implementation-notes-v0_1.md); confirms the substrate posture before Step 3 begins.

Acceptance gate (Checkpoint A).


Step 3 — Item 2 sub-piece A: Companion decision dispatcher (substrate)

What. The Companion-as-Authority dispatcher. Reads inbound grant_request_received events; reads supporting Memory; composes the decision prompt; runs the LLM call; produces a GrantDecisionProposal held assertion.

Step 0 inspection. Before implementing, CC reads:

Build. Per §12.1 through §12.4 (dispatcher logic, Memory reads, voice composition, held assertion). Decision prompt template at prompts/credit_voice/grant_decision_prompt.md.

Tests. Per §12.8 sub-piece A (~15–20 tests).

Acceptance gate. All Step 3 tests pass; held assertion lands with correct shape; voice template loads cleanly; LLM call is mocked at the test layer with a fixture proposal.

Halt conditions.

Step 4 — Item 2 sub-piece B: Approval surface and dispatch (substrate + frontend)

What. <GrantDecisionApprovalCard> (sibling component to <ProposalApprovalCard>, both wrapping <NotificationCard>); Approve callback invokes seam.issue_grant; Decline callback advances proposal state.

Step 0 inspection. Before implementing, CC reads:

Build. Per §12.5 / §12.6 (approval / decline callbacks) and §13 (frontend component).

Tests. Per §12.8 sub-piece B (~10–15 backend tests) + §13.4 (~10–12 frontend vitest tests).

Acceptance gate. All Step 4 tests pass; <GrantDecisionApprovalCard> renders correctly; Approve issues grant via the seam; Decline advances state to declined; sibling-card discrimination works (reconciliation proposals route to <ProposalApprovalCard>, not to <GrantDecisionApprovalCard>).

Halt conditions.

Step 5 — Item 2 sub-piece C: Voice and prompt finalization

What. Voice template prompts/credit_voice/grant_proposal.md lands; loader integration confirmed at loomworks/orchestration/credit_voice.py; AI-invisibility carve-out comment present.

Step 0 inspection. Before implementing, CC reads:

Build. Per §15. First-iteration content per §15.4 with structural acceptance criteria.

Tests. Per §15.5 (~5–8 tests).

Acceptance gate. All Step 5 tests pass; voice template loads; AI-invisibility comment present; end-to-end flow (form submission → dispatcher → held assertion → rationale via voice template) completes successfully against fixture data.

Halt conditions.

Step 6 — Reserved buffer

(reserved — buffer for amendments arising from Steps 1–5)

Per P50-D13 reserved-slot-not-skipped semantics. Unconsumed if no amendment arises.

Step 7 — Reserved buffer

(reserved — buffer)

Step 8 — Reserved buffer

(reserved — buffer)


Checkpoint B — final, before tagging

CC produces implementation notes v0.2 (or higher if amendments consumed reserved slots): build summary across all Steps; any in-flight resolutions; methodology findings to record (per the Phase 49 v0.2 precedent: knowledge artifacts feeding manifest v0.36, distinct from build carry-forward). Operator confirms before Phase 50 tag lands.

Acceptance gate (Checkpoint B).

Tag. phase-50-companion-as-authority-and-public-form on both engine and Operator Layer repos; annotated tags pushed.


17. Acceptance gates

Seventeen items. One per active build step (5) + two checkpoint gates (A and B) + ten integration / cross-cutting gates spanning the slices. Mirrors the Phase 49 v0.3 19-item shape, scaled for Phase 50's narrower scope (5 active steps vs Phase 49's 9 active + 4 reserved).

| # | Gate | Where verified | |---|------|---------------| | 1 | All Step 1 tests pass; Phase 49 model_profile callers' tests unchanged. | Step 1 | | 2 | All Step 2 tests pass; rate-limit decorator does not affect authenticated routes; CORS dev/test posture unchanged when env var unset. | Step 2 | | 3 | Checkpoint A: substrate test count baseline + ~35–43 new tests; Steps 1 & 2 implementation notes draft v0.1 records build summary. | Checkpoint A | | 4 | All Step 3 tests pass; held assertion lands with correct shape; voice template loads cleanly. | Step 3 | | 5 | All Step 4 tests pass; Approve invokes seam.issue_grant with grant_kind='form_initiated'; Decline advances state to declined. | Step 4 | | 6 | All Step 5 tests pass; voice template loads; AI-invisibility carve-out comment present at top of file. | Step 5 | | 7 | Checkpoint B: substrate +~66–86 new tests; Operator Layer +~10–12 vitest tests; eslint/tsc/build clean; working tree clean. | Checkpoint B | | 8 | Proposal lands as held assertion. A grant_request_received form submission produces a held GrantDecisionProposal assertion in CME Memory with provenance pointing at the source event. | Steps 2–3 integration | | 9 | Approve invokes seam.issue_grant. seam.issue_grant is called with grant_kind='form_initiated', initiated_by=None, and the proposal's recipient_email / asset_id / amount. | Step 4 integration | | 10 | Decline path symmetric. on_decline advances proposal state to declined; no seam.issue_grant call; no grant row created. | Step 4 integration | | 11 | Override resolution: most-specific-scope wins. With referrer_id-scoped + campaign_ref-scoped + global overrides all active, the referrer_id override fires; flow metadata records asset_id_source='override' and the right override_assertion_id. | Step 1 integration | | 12 | Phase 49 callers untouched after parameterization. All Phase 49 read_credit_management_assertions callers (with implicit category='model_profile') continue to work; Phase 49 test suite passes unchanged. | Step 1 regression | | 13 | slowapi 429 fires. 11th request from same IP within an hour returns 429 with Retry-After header. | Step 2 integration | | 14 | CORS allowlist enforces. With LOOMWORKS_CORS_ORIGINS=https://loomworks.com, preflight from that origin succeeds; preflight from https://attacker.example is rejected. | Step 2 integration | | 15 | System-actor sentinel UUID stable. Two form submissions from different submitters produce events with the same actor.contributor_id; sentinel UUID is deterministic across process restarts. | Step 2 integration | | 16 | Voice template loads via the credit_voice loader. prompts/credit_voice/grant_proposal.md resolves through the loader at loomworks/orchestration/credit_voice.py; rendered output reaches the <GrantDecisionApprovalCard> rationale field via the held-assertion payload. | Step 5 integration | | 17 | AI-invisibility carve-out comment present. Top-of-file comment in grant_proposal.md mirrors Phase 49's three credit_voice templates' shape (verified by string-match on the comment marker). | Step 5 |


18. Halt conditions per build step

Halt-and-surface is preferred to draft-and-hope. CC halts on any of the following and reports for guidance.

18.1 Cross-cutting halt thresholds (apply to all steps)

Per scoping v0.2 §9 + standard Phase 47/48/49 thresholds:

  1. Step 0-equivalent CR-drafting-time verifications surface architectural friction beyond naming. (E.g., the slowapi integration reveals a deeper FastAPI middleware-ordering issue.) Halt and produce an amendment — or, if friction accumulates, surface as Operator-elective amendment scoping document (Finding 6 path).
  2. System-actor sentinel ActorRef does not validate at runtime. If the deterministic UUID approach for the form-submission contributor fails validation against any substrate constraint (FK to contributor table with NOT NULL, etc.), halt and surface for v0.3 architectural decision.
  3. slowapi integration reveals incompatibility with existing FastAPI patterns. Unlikely (well-maintained library), but if it surfaces, halt and propose either alternate library or in-memory bucket fallback.
  4. Test count drift. If at any checkpoint the substrate test count diverges from the post-Phase-49 baseline (2,023) by more than ±3 unexpectedly, halt and reconcile before proceeding.
  5. >30 tests touched. Standard Phase 47/48/49 blast-radius halt threshold. If a single change touches more than 30 existing tests, halt and surface for review before continuing.
  6. Any divergence from v0.2 scoping decisions. Per handoff §5 drafting discipline. Decisions P50-D1 through P50-D14 are settled; the build does not re-decide them.

18.2 Step-specific halt conditions

Beyond the cross-cutting thresholds, each active build step carries its own halt conditions per §16 above:

18.3 What halt-and-surface looks like

Per Phase 49 substrate-friction-discipline-pattern (Finding 6) precedent: build does not silently work around friction. CC produces a halt summary describing the friction; Operator and Claude.ai decide architecturally with options + halt-threshold review; sub-step lands before continuing. The canonical instance is phase-49-step-4-amendment-scoping-v0_1.md.

If the friction is naming-only (e.g., a column name, a function path), CC absorbs in-flight without ceremony per the standard pre-flight discipline. If the friction crosses the architectural-significance threshold, halt and surface.


19. Test list

Estimated ~75–98 total: ~66–86 substrate + ~10–12 frontend (per scoping v0.2 §6.3). Grouped by build step; counts are approximate.

19.1 Step 1 — Item 8 + read seam parameterization (~14–17 substrate tests)

Read seam parameterization (~5 tests):

Override resolver (~5 tests):

Observer extension (~5–7 tests):

19.2 Step 2 — Item 3 engine half + slowapi + CORS (~21–26 substrate tests)

Endpoint happy path / validation (~6 tests):

Rate-limit (~4 tests):

CORS (~3 tests):

System-actor sentinel (~5 tests):

Memory event integration (~3 tests):

19.3 Step 3 — Item 2 sub-piece A: dispatcher substrate (~15–20 substrate tests)

19.4 Step 4 — Item 2 sub-piece B: approval surface and dispatch (~10–15 substrate + ~10–12 frontend tests)

Substrate (~10–15 tests):

Frontend (~10–12 vitest tests):

19.5 Step 5 — Item 2 sub-piece C: voice and prompt finalization (~5–8 substrate tests)

19.6 Total estimate

| Step | Substrate | Frontend | |------|-----------|----------| | Step 1 | 14–17 | — | | Step 2 | 21–26 | — | | Step 3 | 15–20 | — | | Step 4 | 10–15 | 10–12 | | Step 5 | 5–8 | — | | Total | ~66–86 | ~10–12 |

Build time estimate: 2 hours – 3 hours total (per scoping v0.2 §6.3).


20. Carry-forward to Phase 51

Items intentionally not built in Phase 50, recorded for Phase 51 scoping. Mirrors Phase 49 v0.3 §17's explicit list shape.

20.1 Phase 51 primary build candidates

  1. Marketing site itself. Static-site deliverable; consumes the POST /authority/grant-request engine contract Phase 50 ships. Phase 51 scoping decides whether the marketing site lands as a separate repo or co-located.
  1. SMTP automation for grant email delivery. Phase 50 alpha posture: the Operator manually copies the claim URL from the approval response and emails it. Phase 51 wires SMTP + a Companion-driven email composition flow.
  1. Captcha for the public form. Defer until rate-limiter alone proves insufficient; Phase 51 surface item if abuse signals warrant.

20.2 Phase 51 secondary or conditional

  1. Threshold-driven Companion autonomy. Alpha is always-require-approval (P50-D2). Phase 51+ may introduce a config flag for auto-issue of low-stakes grants (small amounts to known-good audiences). Bounds and policy expressed via Memory assertions.
  1. Modify-the-proposal action on <GrantDecisionApprovalCard>. Alpha is Approve/Decline only. Phase 51+ may add an Edit action that lets the Operator adjust credit_type or amount before approving.
  1. Multi-Companion-instance cross-checking on grant decisions. Single-Companion alpha; multi-instance cross-checking would let two Companion instances reach independent proposals and surface divergence to the Operator. Future work.

20.3 Continuing parallel work

  1. Item 6 — Operator content authoring (continuation). Per P49-D12 / Phase 49 carry-forward, the Operator authors model profile assertions and other CME Memory content in parallel with build phases. Continues into Phase 51.
  1. Voice tuning iterations. Per P50-D14. Voice quality emerges through Operator iteration on grant_proposal.md. Iteration that lands after Phase 50 tag is ongoing Operator work, not a Phase 50 amendment. Phase 51 may absorb tuning findings into a methodology consolidation pass.

20.4 Substrate-gap-dependent or deferred

  1. Item 10 — Phase 42 turns reconciler coverage. Phase 48 reconciliation evaluator covers Phase 31 companion_turn events; Phase 42 conversation_turns doesn't carry credits_consumed. Substrate-gap-dependent; future Phase 42 amendment work.
  1. Item 12 — Reactivation in-session chrome (conditional). Defer until cross-tab edge case becomes real.

20.5 Methodology-level findings recorded for next manifest consolidation pass

Per Phase 49 v0.2 precedent (T2 amendment): findings from the Phase 50 build are recorded in phase-50-implementation-notes-v0_N.md at Checkpoint B, distinct from build carry-forward. Candidate findings:

These are knowledge artifacts, not build carry-forward. They land in implementation notes during construction and feed the next manifest update / methodology consolidation pass (what-dunin7-is-building-v0_21.md).


21. Kickoff prompt for CC

Paste-ready. Mirrors the Phase 49 kickoff shape. Run on DUNIN7-M4 in a fresh Claude Code session against the engine repo at /Users/dunin7/loomworks-engine.


> Read the Change Request document at the path I supply below. This is
> CR-2026-065 v0.1, the Phase 50 Change Request (first version; no prior
> Phase 50 CR exists). You are the executing agent named in the CR.
>
> CR path: ~/Downloads/phase-50-cr-companion-as-authority-and-public-form-v0_1.md
> (confirm the latest approved version if more than one is present in
> Downloads).
>
> v0.1 drafts against:
>   - loomworks-phase-50-scoping-note-v0_2.md (authoritative scope; absorbs
>     Step 0 findings)
>   - phase-50-step-0-findings-v0_1.md (engine repo
>     docs/phase-impl-notes/; verified live-codebase state, absorbed into
>     scoping v0.2)
>   - loomworks-phase-50-cr-drafting-handoff-v0_1.md (drafting handoff)
>
> Code baseline:
>   - engine: tag phase-49-companion-intelligence at f27aba3
>     (annotated tag object cf24958). 2,023 tests passed, 26 skipped,
>     Alembic head 0064, working tree clean.
>   - Operator Layer (DUNIN7/loomworks): tag
>     phase-49-companion-intelligence at 6a3653b (annotated tag object
>     9c8c116). 128 vitest passed, 28 test files, 11 prerendered routes,
>     eslint/tsc/build clean.
>
> Per CR §3.4: archive this CR to
> docs/phase-crs/phase-50-cr-companion-as-authority-and-public-form-v0_1.md
> at Step 0 before Step 1 begins.
>
> Per CR §3.3: run pre-flight Step 0 (15 items). The eight scoping-time
> verifications absorbed into v0.2 (V1–V8) are not re-run; the pre-flight
> items here are CR-time deltas only. Naming-only divergences absorb
> in-flight per the standard discipline; architectural divergences halt
> and surface (see CR §18 halt conditions).
>
> Per CR §16: five active build steps + three reserved buffer slots
> (Steps 6–8 reserved-not-skipped per P50-D13). Two checkpoints — A
> after Step 2, B after Step 5 (final, before tagging). Standard
> auto-mode posture: Steps 1–2 accept auto-mode-proceed; Checkpoint A
> halts until Operator confirms; Steps 3–5 auto; Checkpoint B (final)
> halts for tagging.
>
> Per CR §4: fourteen construction decisions (P50-D1 through P50-D14)
> are settled. CC executes against them; does not re-decide. P50-D14
> in particular: voice quality emerges through Operator iteration after
> wire-up; first-draft voice content shipped at Step 5 is the as-shipped
> baseline; iteration after the Phase 50 tag is ongoing Operator work,
> not a Phase 50 amendment trigger.
>
> Per CR §18: halt-and-surface is preferred to draft-and-hope. Halt
> thresholds include: substrate-friction-discipline-pattern (Finding 6
> trajectory); system-actor sentinel runtime validation failure; slowapi
> integration incompatibility; test count drift > ±3 unexpectedly;
> >30 tests touched by a single change; any divergence from v0.2
> scoping decisions.
>
> Implementation notes at Checkpoints A and B:
> docs/phase-impl-notes/phase-50-implementation-notes-v0_1.md (and
> v0_2.md if revised at Checkpoint B). Records build summary for each
> step, in-flight resolutions, and methodology findings (per CR §20.5).
>
> Tag at completion: phase-50-companion-as-authority-and-public-form
> (annotated, on both engine and Operator Layer repos). Push tags after
> Checkpoint B.

After CC reports build summary at completion, a fresh scoping chat opens for Phase 51 with the carry-forward from §20 as relevant.

If a mid-build amendment surfaces (Finding 6 trajectory — Operator-elective amendment scoping), the discipline is established: build doesn't halt; Operator decides architecturally with options + halt-threshold review; sub-step lands before continuing. Phase 49's phase-49-step-4-amendment-scoping-v0_1.md is the canonical instance.


DUNIN7 — Done In Seven LLC — Miami, Florida Phase 50: Companion-as-Authority and Public Form — CR v0.1 — 2026-05-08