Skip to content

Design note: one identity model behind the duplicate-claim, dual-attribution, and 403 symptoms #228

Description

@khaliqgant

Design note, not a defect report. Filed deliberately un-dispatchable — no readiness label, no title prefix. It exists so #211, #221, and two blockers hit on #225 are worked as one change rather than four.

Thesis

Factory has no first-class notion of who is acting or what is being acted on. Both are inferred from whichever transport a code path happened to pick, so identity is a consequence of transport selection rather than an input to it. Four symptoms follow.

The four symptoms

1. Deduplication keys on a location, not a work unit (#211).
issueKey = ${issue.key}:${issue.uuid}:${issue.path} — the claim key embeds the Relayfile sense path. The same work unit arriving GitHub-native carries a different key, uuid and path, hashes to a different claim, and the lease never sees a conflict. That is the AR-448 duplicate-dispatch shape.

2. Writes carry whichever identity the transport implies (#221).
PR creation goes through the app (author: 'app'). The babysitter shells out to gh, which authenticates as whatever local user is logged in. One product, two audit trails, and the difference is invisible from outside until you read the source.

3. A finished change could not be published by its own author (#225).
PR publication through the app-authored writeback path failed with GitHub 403 at the app's repository-permission boundary. A readback confirmed the minted token's grants were correct — the denial was the identity's capability, not a scope bug. The same operation succeeded under a different identity.

4. A read route refused a caller for being the wrong kind of identity (#225).
The authenticated Cloud GitHub read returned Forbidden: that route requires a deployed sponsor persona, and a local workspace join is not one. The fallback that replaced it is unauthenticated, which is why private-repo dispatch still does not work.

Why these are one cause, not four

Each subsystem picks a transport, and identity falls out of that choice:

Concern Keys on today Should key on
Claim / dedup the sense path the issue arrived by the work unit, provider-native
Write attribution which client the call site chose a declared actor
Authorization whatever credential that client carries the declared actor's capability

Nobody chose two audit trails or a path-keyed claim. They are what you get when identity is implicit: every boundary infers it independently, and the inferences disagree.

The fix, in two axes

Axis A — work-unit identity, independent of surface.
Claims, dedup and cross-surface reconciliation must key on a provider-native identity (owner/repo/number, or the provider's node id) and never on a sense path. A path is a location, not an identity; the same unit has several, and new surfaces mint more.

This is not speculative — the codebase has already discovered it locally. #225's eligibility derivation had to match on GitHub identity precisely because a path could not reliably reconstruct the record: the real uuid is built from GitHub's node_id/id, so a path-keyed durable lookup would not match. One subsystem already had to invent work-unit identity to be correct. #211 is that same discovery generalised to claims.

Axis B — actor identity as an explicit input.
Every outbound effect declares an actor; the transport is selected from the actor rather than the actor being whatever the transport implies. Concretely:

  • One enumerated set of actors (app installation, operator credential, workspace/persona) with declared capabilities.
  • Call sites request an effect and an actor; no call site picks a client directly.
  • Capability is checked against the declared actor before the attempt, so symptoms 3 and 4 fail with "this actor cannot do this here" instead of a bare 403/Forbidden.
  • Attribution is then a property of the actor, which makes symptom 2 a configuration question rather than an accident.

What this buys, concretely

  • A duplicate dispatch across two surfaces becomes impossible by construction rather than by dispatcher discipline.
  • "Who wrote this comment" has one answer per effect, chosen deliberately.
  • The private-repo read question becomes answerable: which actor can read a private repo, and is it available in this context — rather than the current dead end where the only authenticated route rejects the only available identity.

Deliberately out of scope

Not a rewrite of the claim machinery. Leased claims with fencing epochs, mutex-guarded locally and Durable-Object-backed when hosted, are sound and fail closed. Only the key changes. Likewise the app-authored write path is correct as a choice; the defect is that it is not a choice everywhere.

Suggested sequencing

  1. Axis A first. It is the smaller change, has a live precedent in the codebase, and closes the duplicate-dispatch class.
  2. Axis B's enumeration and capability pre-check next, which converts two live blockers from opaque denials into stated preconditions.
  3. Attribution unification last — it is the most visible symptom but the least dangerous while it persists.

Open question this note does not answer

Whether a deployed sponsor persona is obtainable for the Factory context, or whether the no-GitHub-token constraint should be relaxed. That is a product decision, not a design one, and it currently gates private-repo dispatch entirely. It is the sponsor-persona question referenced in symptom 4.

Related: #211, #221, #226. Symptoms 3 and 4 are recorded in #225's branch notes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions