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.
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:
register_action_dispatcher with delegation_required=True (the default — Companion-mediated, the production exercise at delivery-class scale).
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.
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.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.LOOMWORKS_CORS_ORIGINS); production deploys add the marketing-site origin (https://loomworks.com per deployment strategy v0.2). Dev/test posture unchanged.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.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.register_action_dispatcher(..., delegation_required=True, on_decline=<callback>) — the Companion-mediated default, Finding 5 inheritance.<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.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).read_credit_management_assertions parameterized. One-line refactor adding category: str = "model_profile" parameter; backward-compatible default preserves Phase 49 callers._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.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).<GrantDecisionApprovalCard> (alpha is Approve/Decline only).grant_proposal.md (per P50-D14 below).what-dunin7-is-building-v0_21.md (separate work; runs parallel).DUNIN7/loomworks-engine): 2,023 tests passed, 26 skipped, Alembic head 0064, working tree clean on main. Tag phase-49-companion-intelligence at engine f27aba3 (annotated tag object cf24958).DUNIN7/loomworks): 128 vitest passed, 28 test files, 11 prerendered routes, eslint/tsc/build clean. Tag phase-49-companion-intelligence at frontend 6a3653b (annotated tag object 9c8c116).DUNIN7/loomworks-ui): unchanged from Phase 48 baseline; not in Phase 50 scope.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.
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 |
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.
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.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.<NotificationCard> props and composition shape. The Operator Layer component file. CR §13 specifies <GrantDecisionApprovalCard> composition._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.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._on_api_key_saved_for_conversion exact location and signature. Confirm file path and function signature; CR §14 specifies the override-read insertion point.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.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.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.POST /claim/grant, POST /signup). CR §10 mirrors the validation/error-response posture.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).
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.
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 |
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.
Per scoping v0.2 §5.1. Substrate-side surface, organized by module.
loomworks/credit/companion_authority/decision_dispatcher.py (or comparable; [CC verifies] against any preferred pattern from Phase 49's reconciliation dispatcher placement). Hosts the Companion-as-Authority dispatcher: reads grant_request_received events, composes the decision prompt via the credit_voice loader, runs the LLM call, produces a GrantDecisionProposal held assertion. Registers via register_action_dispatcher with delegation_required=True and an on_decline callback (per CR §12 below). The module imports at lifespan-startup time so registration happens once.loomworks/credit/companion_authority/override_reader.py (or comparable). Hosts the conversion-credit-policy override resolver: reads override assertions via the parameterized read_credit_management_assertions(category="conversion_credit_policy"), resolves most-specific-scope wins (per CR §14 below).loomworks/api/routes/authority.py (or comparable; [CC verifies] against existing public-endpoint placement — Phase 47/48 POST /claim/grant and POST /signup are the precedents). Hosts POST /authority/grant-request. The endpoint writes the grant_request_received Memory event via the system-actor sentinel and returns a 202.loomworks/credit/cross_engagement_memory.py — read_credit_management_assertions parameterized: category: str = "model_profile" added as a kwarg with backward-compatible default. Phase 49's model_profile callers untouched. (CR §9.)loomworks/credit/conversion_observer.py (or wherever _on_api_key_saved_for_conversion actually lives — [CC verifies]) — extended to read overrides via the parameterized seam, resolve most-specific-scope wins, and record asset_id_source and override_assertion_id in the conversion-flow metadata. (CR §14.)[CC verifies] exact module path) — slowapi limiter registered as middleware; LOOMWORKS_CORS_ORIGINS env var consulted for the CORS allowlist. (CR §10.)pyproject.toml — slowapi added as a dependency.
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.
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.)
Per scoping v0.2 §5.2.
prompts/credit_voice/grant_proposal.md — grant-decision-rationale voice template. Loaded at Step 5 through the credit_voice loader (per P50-D10).
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.
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.
[CC verifies] exact path — likely prompts/credit_voice/grant_decision_prompt.md or comparable; CC mirrors the Phase 49 dispatcher prompt placement convention).
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.
Per scoping v0.2 §5.3. Substantively narrow: one new component, no new top-level routes.
src/components/notifications/GrantDecisionApprovalCard.tsx (or comparable; [CC verifies] against <ProposalApprovalCard>'s actual placement in the Operator Layer repo). Sibling component to <ProposalApprovalCard>. Both wrap <NotificationCard> (the Phase 46 §13.1 component) — composition, not subclass extension.
Per P50-D9 and <NotificationCard>'s data-driven rendering pattern (Phase 46 §13.1):
<GrantDecisionApprovalCard> reads notification metadata (notification id, payload, approval status).POST /operator/notifications/{id}/approve and POST /operator/notifications/{id}/decline — Phase 45's existing dispatch surface.on_decline callback.
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.
Vitest tests verify:
<GrantDecisionApprovalCard> renders title with recipient email, credit type, amount.<GrantDecisionApprovalCard> renders rationale body.<NotificationCard> composition works (props pass through; no double-rendering of action buttons).Estimated 10–12 frontend tests (per scoping v0.2 §6.3).
Item 8 prerequisite. This section lands at Step 1 alongside item 8.
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).
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.
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.
test_read_credit_management_assertions_default_category — calling without category returns model_profile assertions (regression test for Phase 49 callers).test_read_credit_management_assertions_explicit_model_profile — calling with category="model_profile" returns the same set (explicit equals default).test_read_credit_management_assertions_conversion_credit_policy — calling with category="conversion_credit_policy" returns only assertions tagged with that category.test_read_credit_management_assertions_unknown_category — calling with a category that no assertion uses returns an empty list.test_read_credit_management_assertions_filter_path_uses_metadata_category — verifies the live filter path matches Phase 49's corrected shape.Per scoping v0.2 §4.2.1, §4.2.2. Lands at Step 2.
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.
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.
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.
Per scoping v0.2 §6.1 step 2 (~21–26 tests):
Retry-After.LOOMWORKS_CORS_ORIGINS=https://loomworks.com, preflight from https://loomworks.com returns appropriate CORS headers.https://attacker.example is rejected.event_kind == 'grant_request_received'; actor kind="contributor" with the deterministic UUID; payload carries email, display_name, use_case, audience, source.Per scoping v0.2 §3.6 / §4.2.3 / P50-D3. Lands at Step 2 alongside the public endpoint.
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.
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,
)
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.
kind="contributor"; contributor_id is the deterministic UUID._BOOTSTRAP_ACTOR's UUID and from any other deterministic actor in the codebase.append_event accepts the sentinel ActorRef and writes the event with the expected actor.kind and actor.contributor_id fields.actor.contributor_id (origin lives in payload, not actor).Per scoping v0.2 §4.1. The substantive substrate section. Lands at Steps 3–4.
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:
grant_request_received Memory event lands in Credit Management Engagement Memory (via §10/§11 above).category="model_profile"), and any campaign / referral / eligibility-heuristic assertions in CME Memory.GrantDecisionProposal held assertion in CME Memory with provenance pointing at the form-submission event.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./operator/notifications/{id}/approve), the dispatcher's process_approval callback invokes seam.issue_grant(...) and the grant ships.on_decline callback advances the proposal state to declined; no grant issues.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).
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.
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:
category = "grant_decision_proposal" (or comparable).state = "held" (pending Operator approval).seam.issue_grant invocationPer 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.
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.
grant_request_received event. If the event is replayed (e.g., during a reconciliation re-walk), the proposal-author logic checks for an existing held assertion with the same source_event_id and skips. Idempotency check shape [CC verifies] against any analogous Phase 49 pattern.seam.issue_grant layer (Phase 47 §10.1's transaction model handles concurrent-approval edge cases).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):
grant_request_received event; reads form payload; reads Memory assertions; produces a held assertion.action_type.held (not issued, not declined).Sub-piece B — approval surface and dispatch (Step 4):
process_approval loads the held assertion; invokes seam.issue_grant with the right kwargs; advances state to issued.on_decline advances state to declined; does not invoke seam.seam.issue_grant is called with grant_kind='form_initiated' and initiated_by=None.claim_url (per Phase 47 §10.1 return shape).delegation_required=True); a person-actor is not built (that would be the Operator-direct path).claim_url).declined; cannot transition to issued).register_action_dispatcher signature mismatch. If pre-flight reveals the function's signature has shifted since Phase 49 (e.g., delegation_required removed; on_decline parameter renamed), halt and surface — do not draft around the divergence.loomworks/orchestration/credit_voice.py cannot be imported or does not return template content, halt and surface — the path is locked by P50-D10 / V7, but the symbol is [CC verifies].Per scoping v0.2 §4.1 / §5.3 / P50-D9. Frontend component; lands at Step 4 (sub-piece B).
<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:
<NotificationCard> slots).POST /operator/notifications/{id}/approve (existing Phase 45 endpoint).POST /operator/notifications/{id}/decline (existing Phase 45 endpoint).
Exact prop shape and rendering details [CC verifies] against <ProposalApprovalCard>'s implementation — CC mirrors that pattern rather than inventing a parallel one.
<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.
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.
Per scoping v0.2 §6.3 (~10–12 frontend vitest tests, distributed across §8 and here):
<GrantDecisionApprovalCard> renders with title carrying recipient_email.onApprove; <NotificationCard> transitions to executing/complete state.onDecline; <NotificationCard> transitions to declined state.<NotificationCard> props pass through; no double-rendering of action buttons or timestamp.action_type='reconcile_proposal' routes to <ProposalApprovalCard>, not to <GrantDecisionApprovalCard> (and vice versa).Per scoping v0.2 §4.3. Lands at Step 1.
_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.
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.
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,
},
)
{
"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.
metadata->>'reason' = 'conversion_detected' and metadata->>'converted_person_id' = '<id>') is unaffected. The new metadata keys (asset_id_source, override_assertion_id) do not participate in the idempotency check.
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.
Per scoping v0.2 §6.1 step 1 (~14–17 tests):
asset_id_source = 'override'; override_assertion_id recorded.asset_id_source = 'default_mirror'; override_assertion_id = null; default-mirror behavior preserved.effective_from > now does not fire.expires_at < now does not fire; null expires_at never expires.asset_id_source and override_assertion_id keys present when override fires; absent or null when default-mirror.read_credit_management_assertions callers (model_profile category) continue to work after the parameterization (regression test from §9.4 also covers this).override_asset_id = 'loomworks_credit_opus', conversion writes opus credit to referrer.Per scoping v0.2 §5.2 / P50-D10 / P50-D11 / P50-D14. Lands at Step 5.
prompts/credit_voice/grant_proposal.md — the voice template that produces the proposal rationale. Loaded via the credit_voice loader at loomworks/orchestration/credit_voice.py (path per V7; symbol [CC verifies]).prompts/credit_voice/grant_decision_prompt.md (or comparable; [CC verifies] placement convention) — the system-prompt template the dispatcher composes before the LLM call.
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).
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.
CC authors first-iteration content at Step 5 with structural acceptance criteria only (per P50-D14):
<GrantDecisionApprovalCard> rationale field via the held-assertion payload.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).
Per scoping v0.2 §6.1 step 5 (~5–8 tests):
load_credit_voice("grant_proposal") (or the symbol per [CC verifies]) returns the template content.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.
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:
loomworks/credit/cross_engagement_memory.py — current read_credit_management_assertions signature (pre-flight item 11)._on_api_key_saved_for_conversion location and signature (pre-flight items 7, 8).read_credit_management_assertions — confirms the parameterization is backward-compatible.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.
POST /authority/grant-request + slowapi + CORS extensionWhat. 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:
POST /claim/grant, POST /signup (pre-flight item 13) — confirms validation/error-response posture._BOOTSTRAP_ACTOR construction site (pre-flight item 5) — confirms deterministic-UUID generation pattern.append_event signature and event_kind constraint (pre-flight item 6) — confirms String(64) and routine-event-kind handling.
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.
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).
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:
register_action_dispatcher exact signature (pre-flight item 2).process_approval and process_decline signatures (pre-flight item 3).loomworks/orchestration/credit_voice.py (pre-flight item 12).
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.
register_action_dispatcher signature has shifted (architectural divergence beyond naming).[CC verifies]).
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:
<NotificationCard> props and composition shape (pre-flight item 4).<ProposalApprovalCard> implementation — pattern to mirror at the sibling level.seam.issue_grant exact signature (Phase 47 §10.1 — confirm against live).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.
<NotificationCard> props don't accommodate the proposal payload shape (forces a <NotificationCard> extension, which is out of scope — propose deferral).seam.issue_grant signature has shifted since Phase 47.<NotificationCard> change (deeper refactor than CR §13 anticipates).
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:
loomworks/orchestration/credit_voice.py (pre-flight item 12, re-confirmed at Step 3).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.
(reserved — buffer for amendments arising from Steps 1–5)
Per P50-D13 reserved-slot-not-skipped semantics. Unconsumed if no amendment arises.
(reserved — buffer)
(reserved — buffer)
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).
main for both repos before tag.
Tag. phase-50-companion-as-authority-and-public-form on both engine and Operator Layer repos; annotated tags pushed.
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 |
Halt-and-surface is preferred to draft-and-hope. CC halts on any of the following and reports for guidance.
Per scoping v0.2 §9 + standard Phase 47/48/49 thresholds:
contributor table with NOT NULL, etc.), halt and surface for v0.3 architectural decision.Beyond the cross-cutting thresholds, each active build step carries its own halt conditions per §16 above:
register_action_dispatcher signature shift; voice loader symbol unresolvable; held-assertion authoring path divergence.<NotificationCard> props don't accommodate proposal payload; seam.issue_grant signature shift; sibling-card discrimination requires <NotificationCard> change; frontend lint/tsc/build fails.
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.
Estimated ~75–98 total: ~66–86 substrate + ~10–12 frontend (per scoping v0.2 §6.3). Grouped by build step; counts are approximate.
Read seam parameterization (~5 tests):
read_credit_management_assertions with default category returns model_profile assertions (regression for Phase 49).category='model_profile' returns the same set as default.category='conversion_credit_policy' returns only assertions with that category metadata.payload->'metadata'->>'category' (Phase 49 corrected shape).Override resolver (~5 tests):
Observer extension (~5–7 tests):
asset_id_source='override'; override_assertion_id recorded; conversion writes to override.asset_id.asset_id_source='default_mirror'; override_assertion_id=null; conversion writes to grant.asset_id.Endpoint happy path / validation (~6 tests):
Rate-limit (~4 tests):
Retry-After.CORS (~3 tests):
LOOMWORKS_CORS_ORIGINS=https://loomworks.com → preflight from that origin succeeds.LOOMWORKS_CORS_ORIGINS unset → dev/test permissive posture preserved.System-actor sentinel (~5 tests):
kind="contributor" and the deterministic UUID._BOOTSTRAP_ACTOR.append_event accepts the sentinel; event written with correct actor fields.actor.contributor_id.Memory event integration (~3 tests):
event_kind='grant_request_received' lands as a routine event (no DST/DRT declaration).grant_request_received event.held.action_type.[CC verifies] per pre-flight item 14).Substrate (~10–15 tests):
process_approval loads held assertion; invokes seam.issue_grant with grant_kind='form_initiated'.seam.issue_grant called with correct kwargs (recipient_email, asset_id, amount, initiated_by=None, campaign_ref, metadata).claim_url (Phase 47 §10.1 return shape).issued.on_decline advances proposal state to declined; does not invoke seam.delegation_required=True path runs; verify_companion_authorization is invoked.declined).Frontend (~10–12 vitest tests):
<GrantDecisionApprovalCard> renders with title carrying recipient_email.onApprove; transitions to executing/complete state.onDecline; transitions to declined state.<NotificationCard> composition: props pass through; no double-rendering.action_type='reconcile_proposal' routes to <ProposalApprovalCard>.action_type='grant_decision_proposal' routes to <GrantDecisionApprovalCard>.load_credit_voice (or symbol per [CC verifies]) returns grant_proposal.md content.| 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).
Items intentionally not built in Phase 50, recorded for Phase 51 scoping. Mirrors Phase 49 v0.3 §17's explicit list shape.
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.<GrantDecisionApprovalCard>. Alpha is Approve/Decline only. Phase 51+ may add an Edit action that lets the Operator adjust credit_type or amount before approving.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.companion_turn events; Phase 42 conversation_turns doesn't carry credits_consumed. Substrate-gap-dependent; future Phase 42 amendment work.
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:
loomworks-closed-loop-engagement-investigation-v0_1.md._BOOTSTRAP_ACTOR-style) extended to a runtime-fired event source. Reusable for any future case where origin is structurally not a known person or Companion.model_profile reads).
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).
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