jlevy / jlevy/tbd

Actor model: tbd cannot express who is accountable separately from who is executing, and Linear models that as a first-class axis

Open
#246 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
79
Forks
9
Avg merge
1d 14h
Merged PRs (30d)
42

Description

Actor model: tbd cannot express who is accountable separately from who is executing, and Linear models that as a first-class axis

tbd has one actor field, `assignee: z.string()`. Linear has two — `assignee` (accountable) and `delegate` (acting) — and its entire agent platform hangs off the second. GitHub has a comparable split via agent assignment. The collapse is lossy, and today it is silent.

This is the same shape as #244: a single tbd enum standing in for two axes that both providers model separately. #244 is the closed end of the status axis; this is the actor axis.

Found while assigning two epics in a ~900-open-bead repository where nearly all execution is done by coding agents. `assignee` is used on **zero** beads there — not from neglect, but because there is nothing useful to put in it: writing the human means the field is constant and carries no information, and writing the agent is not expressible at all.

## The three models, as they actually are

Verified against the installed `get-tbd@0.7.1` dist and the Linear SDK schema rather than recollection.

**tbd** — one opaque string:

```ts
assignee: z.string()
```

The Linear adapter maps it to exactly one field:

```js
assigneeId = patch.assignee === null ? null : await this.res…
```

and the default field-flow rule carries the design assumption in a source comment:

```js
assignee: FieldFlowRule.default("local") // "`assignee` stays local because tracker assignees are people"
```

That comment is correct about 2024 and load-bearing today. It is the assumption this issue is about.

**Linear** — two actor fields, publicly mutable:

| Field | Meaning | Input |
| --- | --- | --- |
| `assignee` | accountable human | `IssueUpdateInput.assigneeId` |
| `delegate` | acting agent | `IssueUpdateInput.delegateId` |

Linear states the taxonomy explicitly as *delegation, not assignment*: the issue keeps a human assignee while the agent is the delegate. Setting `delegateId` automatically creates an `AgentSession` and fires the target agent's `AgentSessionEvent(created)` webhook. Agents are OAuth apps installed with `actor=app`, gated by `app:assignable` / `app:mentionable`, free of a seat, and surfaced natively in delegate-filtered views and Insights segmented by delegate.

**GitHub** — assignees are a list, and agent assignment is a distinct mechanism layered on it rather than a second value in the same slot.

### What the comparison shows

**1. tbd has no representation of an agent actor whatsoever.** `delegateId`, `agentSession`, `app:assignable`, and `actor=app` appear in **zero** files in the tbd dist. This is not a mapping gap in the adapter; the concept is absent from the model.

**2. `user_map` is the only lever, and it is the wrong shape for it.** It maps an alias to an email or user UUID, and it is deliberately closed — unmapped assignees are skipped on push. That closure is the *only* thing today keeping agent names out of a tracker, which means "do not publish this actor" is expressed by omission from a config table rather than by the actor having a kind. Two consequences:

- An agent alias cannot be recorded in tbd at all without either leaking to the tracker (if mapped) or becoming an untyped silent skip (if not).
- A Linear agent is an **app user**, not a person with an email. `user_map`'s value domain cannot name one even if you wanted to, and `delegateId` is a separate mutation field regardless.

**3. The two gates are independent and neither is diagnosable.** `field_sync.fields.assignee` (`local` by default) decides whether the field participates at all; `user_map` decides which identities may be pushed. With the default `local`, a push reports `updated N, skipped 0, failed 0` while the assignee is silently not a candidate. Nothing in `--verbose` names a field that was excluded by flow rule, and nothing distinguishes "not eligible" from "eligible and unchanged". Both gates are correct designs; the absence of a report is the defect.

**4. The single axis forces a false choice that has no good branch.** With one field and agents doing the work, a repository must pick: human everywhere (field is constant, so it carries nothing and Linear's delegate views stay empty), agent everywhere (Linear loses its accountable-human signal, and mapped agent identities pollute a shared workspace), or leave it empty (what actually happens — zero uses across ~900 beads).

The branch that gets picked in practice is "human everywhere", for a reason worth stating plainly: **issues in Linear are managed per human, not per agent.** Linear is where a person decides what is on their plate. An agent identity in the assignee slot answers a question nobody is asking there, while destroying the answer to the one they are. That is precisely why Linear separated the axes, and it is why a tracker-facing tool needs both — not one field that has to mean whichever the caller happened to intend.

## Recommendation

Take Linear's **shape** — two actor axes — without importing its app-user identity model.

```
assignee: # accountable. Unchanged field, unchanged meaning.
delegate: # acting. New. Absent means "same as assignee".
```

and give the identity table an actor kind, so publishability is a property of the actor rather than of its presence in a map:

```yaml
user_map:
josh: { email: josh@example.com, kind: human }
claude: { kind: agent } # no address: never pushed as an assignee
cyrus: { kind: agent, linear_app_user_id: } # pushable as a delegate
```

The plain `alias: email` form should keep parsing as `kind: human`, so this is additive.

Mapping is then total in both directions:

| tbd | Linear | GitHub |
| --- | --- | --- |
| `assignee` (human) | `assigneeId` | assignee |
| `delegate` (agent, no app id) | *not pushed* — local only | *not pushed* |
| `delegate` (agent with app id) | `delegateId` → AgentSession | agent assignment |

Two smaller asks that stand on their own even if the axis is not added:

- **Report fields excluded by flow rule.** A push that says `updated 2` should be able to say which fields were in scope. One line under `--verbose` — `assignee: not eligible (flow=local)` — would have saved a real debugging session.
- **Warn when an assignee is set on a bead whose field flow is `local`.** Silently accepting a write that can never reach the tracker is the same class of quiet loss #244 describes.

## What this deliberately does not propose

Agent **presence** — which session is touching a bead right now — does not belong in a bead field, and this issue is not asking for it. Heartbeat cadence is ~30s, tbd merges `extensions` as whole-object last-writer-wins, and sync-branch commits are the wrong granularity; that state belongs in a database with a TTL lease. `delegate` here is the durable, low-frequency fact of who a unit of work is assigned to, which is exactly the cadence a tracker field is for.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by locating the TypeScript actor schema, Linear adapter, field_sync flow handling, and user_map parsing described in the issue. Trace how assignee eligibility and --verbose reporting currently work. Done means representing separate assignee and delegate axes, supporting actor kinds and provider mappings, while preserving the plain alias-to-email form and reporting excluded fields.

Written by the indexing model from the issue text.

Assessment

Tech stack
github, typescript
Domain
backend-api-design, tooling
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.