DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-157-ova-slice-1-non-member-contribution-pathway-v0_1.md

CR-2026-157 — OVA Slice 1: Non-Member Contribution Pathway — v0.1

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.


1. Executive summary

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.

2. Design decisions

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.

3. The authorizer — 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:

  1. 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.
  2. contribution_credentialaction == "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.

4. The credential — schema and lifecycle

Migration (next head after Step 0 reads it; do not assume 0102). Table contribution_credentials, mirroring 0101_standing_invitations structurally:

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.

5. Endpoints

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.

6. Events and FORAY anchoring

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:

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.

7. Tests

Estimated total: ~34 new tests.

8. Step structure

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.

9. Acceptance gate

  1. Suite green at every step; ~34 new tests passing; baseline reconciled at Step 0.
  2. A non-member with a valid claimed credential contributes; the assertion is held, person-attributed with snapshotted display name, display-numbered, assertion_added-evented with credential_id in metadata.
  3. The same person receives 403 on every membership-gated read — verified by test against a sample and by the fence test asserting the resolver path is untraversed.
  4. A commit-designated member admits the held assertion through the existing transition unchanged; the external person cannot reach it.
  5. The credential lifecycle holds: single-use bind under concurrency, expiry flip, revocation blocking further contribution.
  6. read_credit_management_assertions behavior is byte-equivalent through the authorizer across all three consumers; _alpha_authorizer_stub no longer exists.
  7. Onboard's standing and credit-grant token spaces behave identically to baseline.
  8. add_assertion is byte-untouched; its caller enumeration from Step 0 is in the implementation notes.
  9. No change to any of the 63 membership-gated reads; no access-mode/ACL schema; no seed edit; no Operator Layer change.

10. What this CR does not build

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).

11. Kickoff prompt for the CC session


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