Version. 0.1
Date. 2026-07-28
Status. Change request. Ready for CC execution on DUNIN7-M4.
CR number. CR-2026-157 — assigned per pre-flight item 7: docs/phase-crs/ and the record's change-requests/ both top out at 155, but CR-2026-156 exists at stele/change-requests/CR-2026-156-published-front-door-v0_2.md; 157 is the next free number across repos.
Baseline. Engine main at 1aac8154ca440b831f5ba2bd0fc02d6af890614d (1aac815) — the tree all three session reports read with zero drift. Step 0 reconciles if HEAD has moved.
Grounding. cr-drafting-handoffs/loomworks-ova-slice-1-cr-drafting-handoff-v0_1 (parent); loomworks-ova-slice-1-cr-preflight-report-v0_1 (evidence; all line references below are from it, at 1aac815); scoping-notes/loomworks-ova-reach-enforcement-scoping-note-v0_1; seed v0.12.
Settled decisions consumed. D1 candidate C; D2 seed untouched (v0.13 riders); D3 pathway only — no access-mode declaration, no ACL, no read-side changes to the 63 membership-gated reads.
A person who is never a member of an engagement can be credentialed by its Operator to contribute into it. Their contributions land held, fully attributed via the standard person-anchored ActorRef, FORAY-anchored, and admissible only by the engagement's commit-designated members — the admission gate the scoping note showed assembles free, because a non-member structurally cannot reach the commit transition (pre-flight item 3, HOLDS). They read nothing except their own submissions. No existing read changes; the membership predicate remains the fence.
The credential rides the proven two-token-space pattern as a third sibling space: a contribution_credentials table mirroring standing_invitations, claimed through the ordinary signup ceremony via the existing try-then-fall-through dispatch at onboard.py:100-121, or by a signed-in person via a claim endpoint. The claim binds, not confers: it writes the claiming person onto the credential row itself — nothing platform-level, nothing membership-shaped — which is this CR's answer to the pre-flight's named gap (no precedent for engagement-scoped conferral at claim time: this design needs none).
Authorization runs through the generalized Option E authorizer: a new authorize(actor, action, resource, context) → decision in the authority package, with the non-member contribution check as its first real rule and _alpha_authorizer_stub's semantics migrated as its second (explicit allow-rule, behavior identical across the wrapper's three downstream consumers — the real regression surface per pre-flight item 2).
Engine-only. No Operator Layer changes; the issuance affordance is a follow-on sibling. One migration.
D-157-1 — Authorizer insertion point: the resolver layer, not add_assertion. Pre-flight item 1 observed two candidates: the route body beside _require_contributor (contributions.py:139), and inside add_assertion before display-number allocation (assertions.py:181). This CR inserts at neither. The check fires in a new resolver dependency, get_credential_actor in api/deps.py — the same layer where every existing authorization decision in this engine lives (the Step 0 report's V2: all 63 reads converge on the resolver dependencies). The scoping note's chokepoint rule — "never only at the surface that renders the contribution form" — names the frontend as the forbidden sole location; the resolver layer is engine substrate and is this codebase's authorization chokepoint. Inserting inside add_assertion would inject credential semantics into every internal caller (skills, Companion writes, upload pathway) whose authority is already resolved upstream by membership — wrong blast radius for a slice committed to zero changes on existing paths. add_assertion stays byte-untouched; Step 0 item 3 below enumerates its callers to make the untouched claim verified rather than assumed.
D-157-2 — The claim binds; it does not confer. Both existing token spaces confer platform-level attributes at claim (host_account standing; credit). The contribution credential writes only claimed_by_person_id + claimed_at on its own row. The claimed row is the standing authorization the authorizer consults per contribution. No membership row is ever created — the fence is the feature.
D-157-3 — Status vocabulary extends by one value. The shared lifecycle vocabulary is pending_claim/claimed/expired (0101:94-97). This table adds revoked — the handoff's acceptance shape requires revocation, and revocation of a claimed credential is a distinct state from expiry of an unclaimed one. Deliberate divergence from the sibling, recorded here.
D-157-4 — Person-required, confirmed. Per pre-flight item 4's read: the pattern is instantiated twice, the dispatch seam is shaped for a third branch, everything rides the ordinary signup ceremony, and open sign-up is inherited as-is — no two-tier mechanism enters this slice. The handoff's halt condition did not fire. External contributors are real principals; FORAY attests a person, not a token.
D-157-5 — The stub is removed, not wrapped. _alpha_authorizer_stub (cross_engagement_memory.py:65-85) migrates into the authorizer as an explicit named rule (cross-engagement credit-management read → allow, with its docstring's alpha-scope rationale carried into the rule's comment). Its one call site (:118-125) calls authorize(...) instead; the deny path (:125-131 — log + empty list) is preserved verbatim. The behavior-unchanged guarantee is tested against all three downstream consumers of read_credit_management_assertions: conversion_credit_override.py:182, grant_decision_dispatcher.py:169, orchestration/exhaustion_choice.py:206. This corrects the handoff/scoping-note "one caller" framing: one call site, three consumers; the prior framing stands in those documents per record discipline.
src/loomworks/authority/authorizer.py
New module in the authority package (sibling to standing_invitations.py).
@dataclass(frozen=True)
class AuthzDecision:
allowed: bool
rule: str # the named rule that decided
reason: str | None # populated on deny
async def authorize(
*,
actor: ActorRef,
action: Literal["contribute", "read_cross_engagement_credit"],
resource: ScopeRef, # engagement is the present scope kind
context: dict[str, Any] | None,
db: AsyncSession,
) -> AuthzDecision:
ScopeRef is a small value object (kind: Literal["engagement"], id: UUID) — scope-shaped per the scoping note §5 so wider scopes join the contract without a second interface. Deny is the default: no matching rule → allowed=False.
Two rules at ship:
credit_management_alpha_read — the stub's semantics as an explicit rule: action == "read_cross_engagement_credit" and resource.id == CREDIT_MANAGEMENT_ENGAGEMENT_ID → allow. Comment carries the alpha single-Operator rationale and the swap-to-OVA-proper intent from the stub's docstring.contribution_credential — action == "contribute" and a contribution_credentials row exists with engagement_id == resource.id, claimed_by_person_id == actor.id, status == 'claimed', expires_at in the future → allow. Expired-at-check rows are flipped to expired on read, mirroring validate_and_read_invitation (standing_invitations.py:100-142).
This is the Option E contract's first real production surface; when OVA proper deploys, authorize swaps its implementation without callers changing — the stub's original design intent, now module-public and two-rule.
Migration (next head after Step 0 reads it; do not assume 0102). Table contribution_credentials, mirroring 0101_standing_invitations structurally:
id UUID PK; claim_token VARCHAR(64) UNIQUE (same secrets.token_urlsafe(48) shape); engagement_id UUID FK → engagements.id; action TEXT CHECK (action IN ('contribute')) (single value now, vocabulary-shaped for later); status TEXT CHECK (status IN ('pending_claim','claimed','expired','revoked')); recipient_label TEXT (Operator's human label — "Northwind founder", "customer ref #3"; not an identity claim); expires_at TIMESTAMPTZ NOT NULL; created_by_person_id UUID; claimed_by_person_id UUID NULL; claimed_at TIMESTAMPTZ NULL; revoked_at TIMESTAMPTZ NULL; index on (engagement_id) and (claimed_by_person_id).
Module src/loomworks/authority/contribution_credentials.py, mirroring the sibling's surface: generate_credential_token (delegating to the shared shape); create_contribution_credential (does not commit, per sibling); validate_and_read_credential (typed errors mirroring :29-50's family, including the wrong-token-space False-style contract); bind_credential_for_token — the claim-time seam, SELECT … FOR UPDATE, re-validate atomically, write claimed_by_person_id/claimed_at, single-use, returns False when the token belongs to another token space (the dispatch contract confer_standing_for_token established at :144-200); revoke_credential; list_credentials_for_engagement.
Onboard dispatch — third branch. onboard.py:100-121's try-then-fall-through chain gains: try standing → try contribution credential (bind_credential_for_token) → fall through to seam.claim_grant. Ordering: before credit grants (both are terminal single-space claims; standing stays first per existing comment). The comment block at :100-103 is extended to name three token spaces.
Signed-in claim. POST /me/contribution-credentials/claim — body {claim_token}; binds to the session person via the same bind_credential_for_token. Covers the founder who already has an account; the link works either way.
All new; none of the 63 existing reads or the existing contribution endpoint change.
Issuance (Operator-only per the handoff's lean). POST /engagements/{eid}/contribution-credentials — requires the OPERATOR designation; Step 0 item 5 locates the existing operator-check dependency to mirror rather than minting a new check shape. Body: {recipient_label, expires_at}. Returns the credential with its token once. GET .../contribution-credentials (list, Operator-only). POST .../contribution-credentials/{id}/revoke (Operator-only).
The credential resolver. get_credential_actor in api/deps.py: resolves the session person (existing person resolution, no membership lookup), calls authorize(actor=actor_from_person(person), action="contribute", resource=ScopeRef("engagement", eid), ...); 403 with the decision's reason on deny. Returns a CredentialActor(actor_ref, person_id, credential_id).
External contribution. POST /engagements/{eid}/external-contributions — depends on get_credential_actor; text mode only at ship (file mode is a follow-on; the vertical's external contributors answer questions and state figures — text is the 80% case and keeps the slice small). Calls add_assertion (assertions.py:157-228) exactly as the internal text path does (contributions.py:166-174 shape) with metadata={"source_mode": "external_credential", "credential_id": str(credential_id)}. Lands held (:217), attributed via the standard embedded ActorRef with snapshotted display name (pre-flight item 5), display number minted at add per CR-2026-121.
Own-submission surface. GET /engagements/{eid}/external-contributions/mine — depends on get_credential_actor; returns assertions where wasAttributedTo.id == person_id and metadata.credential_id matches the caller's credential, with state (held/committed/retracted/discarded) and content. Implemented as its own query; traverses none of the membership-gated read routes and takes none of their dependencies.
Three event kinds, registered in _ANCHOR_PRIORITY (foray/anchor_priority.py:31-82) at "standard", each with the comment convention manual_content_contributed (:80) demonstrates — phase/CR citation, what the event captures, priority-by-family-analogy:
contribution_credential_issued — engagement, recipient_label, expiry, issuer.contribution_credential_claimed — credential, claiming person (the bind).contribution_credential_revoked — credential, revoker, prior status.
The contribution itself needs no new event kind: add_assertion already appends assertion_added (:223-229) with full provenance; the credential_id in metadata carries the pathway's audit thread.
read_credit_management_assertions behavior identical through the authorizer — allow path returns assertions, and a forced-deny (monkeypatched rule) still logs cross_engagement_memory.denied and returns empty; exercised through all three consumers' call paths (conversion_credit_override, grant_decision_dispatcher, exhaustion_choice). (~5)False; onboard three-branch dispatch (standing token untouched, credit token untouched, credential token binds); signed-in claim. (~9)get_resolved_actor, per pre-flight item 3's confirmed exclusion). (~3)settings.loomworks_secret_key to TEST_SECRET_KEY per the ExtractionSkill protocol convention.Estimated total: ~34 new tests.
Auto-mode-proceed Steps 0–6; Checkpoint A halts for Operator review; Checkpoint B halts for tag. Two reserved buffer slots per convention (reserved-slot-as-halt-condition-pre-commitment).
Step 0 — Archive + pre-flight. Archive this CR to docs/phase-crs/cr-2026-157-ova-slice-1-non-member-contribution-pathway-v0_1.md. Reconcile baseline: HEAD vs 1aac815 (report drift), full suite count, alembic head (the migration number derives from it). Item 3 of this step: enumerate every caller of add_assertion — the untouched-claim verification D-157-1 requires; if any caller's authority pattern would be disturbed by this CR's additions, halt and surface. Item 5: locate the existing OPERATOR-designation check dependency used by membership-management routes; the issuance endpoints mirror it. Branch cr-2026-157-non-member-contribution from main. Commit: CR-2026-157 step 0: archival and pre-flight.
Step 1 — Migration + model. contribution_credentials table + ORM model. Verification: suite green, alembic upgrade head + full down/up on playground_dev. Commit per step.
Step 2 — Credential module + claim machinery. contribution_credentials.py full surface; onboard third branch; POST /me/contribution-credentials/claim. Lifecycle + dispatch tests.
Step 3 — Authorizer + stub migration. authorizer.py with both rules; cross_engagement_memory.py call site migrated; stub removed. Authorizer + three-consumer regression tests. This step touches nothing else — if the migration ripples beyond the one call site, halt and surface.
Step 4 — Issuance/list/revoke endpoints + the three events. _ANCHOR_PRIORITY entries with template-convention comments.
Step 5 — Credential resolver + external contribution endpoint. get_credential_actor; POST .../external-contributions; fence tests (403 on all membership reads unchanged).
Step 6 — Own-submission surface + admission-composition tests.
Checkpoint A — Live verification (synthetic engagement only; never E0060, E0007, E0030, or E0006). Operator-driven against the dev stack: create synthetic engagement → issue credential → claim via signed-in path → contribute → verify held + attribution + assertion_added provenance → verify every ordinary read 403s for the external person → member admits via existing commit → own-submission shows committed state → revoke → contribute again 403s with the rule-named reason. Destroy the synthetic engagement. Halt for Operator acceptance.
Checkpoint B — Merge + tag. --no-ff to main; tag cr-2026-157-non-member-contribution-v0_1; implementation notes at docs/phase-impl-notes/cr-2026-157-implementation-notes-v0_1.md absorb execution-time findings. Halt before push per standing discipline.
held, person-attributed with snapshotted display name, display-numbered, assertion_added-evented with credential_id in metadata.read_credit_management_assertions behavior is byte-equivalent through the authorizer across all three consumers; _alpha_authorizer_stub no longer exists.add_assertion is byte-untouched; its caller enumeration from Step 0 is in the implementation notes.The access-mode declaration and ACL (Slice 2). Read-side authorizer consultation, middleware, or the three-paths consolidation (Slice 3 — constraint recorded in the scoping note §5). File-mode external contribution (follow-on; text only at ship). The Operator Layer issuance affordance (follow-on sibling CR — engine-first). Trusted-core-proper admission machinery (shared-scope/domain work). Any sign-up volume-limit or two-tier mechanism (seed v0.12 defers it; this CR inherits open sign-up as-is per D-157-4). Credential-only anonymous contribution (rejected in the handoff §2; person-required stands).
Read /Users/dunin7/Downloads/cr-2026-157-ova-slice-1-non-member-contribution-pathway-v0_1.md and execute it.
Baseline: engine main at 1aac815 (reconcile at Step 0; report drift). Branch cr-2026-157-non-member-contribution. Steps 0-6 auto-proceed with per-step commits and green suite at each; Checkpoint A halts for Operator live verification on a synthetic engagement (never E0060/E0007/E0030/E0006); Checkpoint B halts for merge/tag/push confirmation.
Halt-and-surface conditions: Step 0 add_assertion caller enumeration reveals a disturbed authority pattern; Step 3 stub migration ripples beyond the single call site; any architectural divergence from the CR's evidence base (all line references are at 1aac815).
Stage by explicit path throughout; never git add -A; halt before every push.
DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-157 — OVA Slice 1: Non-Member Contribution Pathway — v0.1 — 2026-07-28