Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions aidd_docs/product/usage-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,11 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD
| `ledger/.lock` | CLI | CLI | `{pid, created_at}`; dead process or older than ten minutes: cleared |
| `bindings/.lock` | CLI | CLI | same format. Declarations and snapshots run under it; `task` never takes the ledger lock, which an ingest holds while reading transcripts |
| `bindings/sessions.jsonl` | `task` | hooks, `report`, `task` | `{session_id, task, ticket, none, declared_at, by}`, `by` = `command` \| `hook-intercept` |
| `bindings/branches.jsonl` | `task`, `ingest`, `report` | `report`, `forget` | branch declaration at a moment, with branch creation time from its reflog |
| `bindings/branches.jsonl` | `task`, `ingest`, `report` | `report`, `forget` | branch declaration at a moment, with when the branch took its name, from its reflog |
| `bindings/carries.jsonl` | `SessionStart` hook | hooks, `report` | `{session_id, from, at}` |
| `bindings/processes.jsonl` | `SessionStart` hook | `SessionStart` hook | `{pid, session_id, source, at}` |
| `identity.json` | `identity`, `on` | `report`, `status`, `on` | `{"person_id": "<id>"}`, owner-only |
| git config `--local` `branch.<name>.aiddTask`, `aiddTicket`, `aiddDeclaredAt` | `task` | hooks, `task`, `report` via snapshots | branch declaration; follows a rename, leaves with the branch |
| git config `--local` `branch.<name>.aiddTask`, `aiddTicket`, `aiddDeclaredFor`, `aiddDeclaredAt` | `task` | hooks, `task`, `report` via snapshots | branch declaration, `aiddDeclaredAt` written last, `aiddDeclaredFor` = `<name>`; see [Branch declaration](#branch-declaration) |
| git config `--local` `aidd.telemetry` | `on`, `off`, `forget` | hooks, CLI | `2:<token>` or `off`. Half of consent: alone it grants nothing |

- A line not exactly its store's format is skipped, never guessed. Every JSON Lines store is append-only except the ledger.
Expand All @@ -178,8 +178,25 @@ flowchart TD

- A declaration of no task is `declared-none`.
- Declaring a branch late moves its earlier calls onto the task.
- A branch snapshot with no `declared_at` attributes nothing.
- Sub-agent and advisor calls follow their parent session.

### Branch declaration

A branch is declared only when the command declared it on that branch. Both readers, the CLI (`parseBranchConfig`) and the hook (`lib/git.cjs`), apply the same rule, pinned by the shared fixture.

| git config of the branch read | Declared? | Why |
| --- | --- | --- |
| `aiddDeclaredAt` set, `aiddDeclaredFor` = this branch | yes | written by `task` on this branch |
| `aiddDeclaredAt` set, no `aiddDeclaredFor` | yes | written by cli-v5.5.0 or earlier, which did not stamp. Kept readable: reports and the gate do not change for data already written. Declaring again stamps it |
| `aiddDeclaredAt` set, `aiddDeclaredFor` = another branch | no | carried by `git branch -c` (copy) or `git branch -m` (rename), which move every `branch.<name>.*` key |
| `aiddTask` or `aiddTicket` without `aiddDeclaredAt` | no | set by hand, or a declaration cut short: `task` writes `aiddDeclaredAt` last |
| any declaration key listed twice | no | left by `git branch -C` or `-M` onto a branch that had keys: the copied declaration and the branch's own are mixed |

- **Rename** (`git branch -m A B`): B is undeclared (unless A's declaration is unstamped, see the limits), so the gate asks on B. One declaration on B covers B from when it took the name: the newest reflog entry renaming or copying a branch to B (git or libgit2), else B's oldest entry. Calls made while it was A keep A's task, and on a name used before, the earlier B keeps its own. git moves the keys the same way for a copy and a rename, so the stamp cannot tell them apart; the reflog could, but its entries expire and the declaration would change meaning with them: one question after a rename is the cost.
- **Copy** (`git branch -c A B`): B is undeclared until declared on B.
- **Copy or rename onto an existing branch** (`-C`, `-M`): git keeps both sets of keys, so some are listed twice and the two declarations mix. A branch with any declaration key listed twice is undeclared; declaring again replaces every value.

## Ask

`UserPromptSubmit` (`hooks/prompt-gate.cjs`) blocks a prompt before the model when all hold:
Expand All @@ -193,7 +210,8 @@ flowchart TD
| CLI usable | `aidd` on `PATH` and knows `telemetry task` |

- Anything else passes. A crash exits 0: the hook never fails a prompt.
- Asks once per working branch; switching to an unbound branch asks again.
- Re-checked on every prompt: each prompt on an undeclared working branch is blocked until the branch or the session is declared (`--none` included). A declared branch is never asked again, unless a copy or rename onto it changes its keys; switching to an undeclared one asks.
- Why every prompt and not once: the gate exists so that no token of the work is spent before it has a task ([spec](../tasks/2026_10/2026_10_09_deterministic-tokens/spec.md), [target](../tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md)). "Once per working branch" there meant answered once and remembered; letting the next prompt through would spend tokens unattributed.

Answering never blocks and never reaches the model:
- `! aidd telemetry task ...` in the terminal fires no `UserPromptSubmit`.
Expand Down Expand Up @@ -304,6 +322,11 @@ None observable by the CLI.
| Call read while git config, `.git` or git dir unreadable | `unreadable-consent`, offsets move on: not recovered on repair | - |
| pid reused within one boot by a Claude process whose `startup` was never recorded (e.g. started before `on`) | can take the dead process's last session as predecessor, so `/clear` carries its task | announced, replaced by first declaration |
| `AIDD_TELEMETRY=0` | stops hooks too; a key turned off meanwhile is observed at the next run without it | - |
| Declaration keys edited by hand: every key including the stamp, `aiddTask` and `aiddDeclaredAt` alone (read as unstamped), or a stamp removed or emptied | the branch reads as declared: no reader can tell who wrote a git config key | - |
| Copy or rename of an unstamped (cli-v5.5.0) declaration | the new name inherits it | declare on the new name, or on the original to stamp it before copying or renaming |
| Calls on a name used before, after a copy, rename or recreation and before it is declared again | the earlier generation's declaration still answers in the report, while `status` and the gate say undeclared: a branch with no declaration takes no snapshot, so nothing marks the new generation. Only calls the gate did not hold (unattended runs, IDE extensions) can land there | declare right after reusing a name |
| Reflog entry of the rename or copy expired before B's first declaration | B's start is its oldest remaining entry; on a name used before, an older start can let the earlier B's declaration speak for later calls | declare soon after a rename or copy |
| Branch snapshots recorded before stamping (a copied branch, a hand-set task with a `declared_at`) | keep attributing: attribution reads snapshots, `branches.jsonl` is append-only | - |

## Never leaves the machine

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ coming only from what a person declared, with the previous telemetry removed ent
> **Deviation, 2026-10-12, maintainer decision.** IDE extensions are not asked: VS Code shows
> no hook message, so a block would eat the prompt. Declare in a terminal; it binds the branch.
> Evidence: [target.md](context/target.md).
> **Reading, 2026-10-16.** "At most once" means answered once and remembered for the branch:
> the gate holds every prompt until then, as "before any token of that work is spent" requires.
> See the usage contract, section Ask.
- A session started by `/clear` keeps the task of the session it replaced, says so to the
person, and a later declaration in it re-attributes the whole session.
- Work with no declared task is its own row, with the reason it has none, and is attributed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
status: done
---

# A declaration comes only from the command

## Need

The product rule ([usage-contract](../../../product/usage-contract.md#attribution)): a task and a ticket come only from `aidd telemetry task`. The R1 telemetry audit found two ways around it, and one sentence of the contract that the hook does not do.

| Finding | Observed |
| --- | --- |
| B1 | Both readers of a branch declaration (CLI `parseBranchConfig`, hook `lib/git.cjs` + `lib/binding.cjs`) bind a branch on a hand-set `aiddTask` with no `aiddDeclaredAt`. Attribution treats a snapshot with `declared_at: null` as speaking from the branch's creation. |
| B1 | `git branch -c A B` copies every `branch.A.*` key, `aiddDeclaredAt` included: B inherits a declaration it never received, and its calls go to A's task. |
| M8 | The contract says the gate "asks once per working branch". It blocks every prompt until the branch is declared. |

## Decisions

| # | Decision | Why |
| --- | --- | --- |
| 1 | The command also writes `branch.<name>.aiddDeclaredFor = <name>`, before `aiddDeclaredAt`, which stays last. | Ties the declaration to the branch it was made on. A copy or a rename moves the keys under another name; the value keeps the old one. |
| 2 | A branch is declared only when `aiddDeclaredAt` is set and `aiddDeclaredFor`, when present and non-empty, equals the branch read. Otherwise it is undeclared: no task, no ticket, no `none`. | `aiddDeclaredAt` is the last key the command writes, so it alone says the command finished. The stamp rejects a copied declaration. |
| 3 | A declaration with no `aiddDeclaredFor` (written by cli-v5.5.0 and earlier) is read as before, if `aiddDeclaredAt` is set. Declaring again stamps it. | Beta, but data written by a released CLI keeps its meaning. Rejecting it would make `status` and the gate ask again on every branch already declared, for a hole only a copy opens. |
| 4 | `git branch -m A B` leaves B undeclared (a stamped declaration; see the limits for an unstamped one): the gate asks on B, and one declaration on B covers it from when it took the name B. Calls made on A keep A's task. | git moves the config keys the same way for a copy and a rename, so the stamp cannot tell them apart; the reflog could, but its entries expire, and a declaration that holds only while an entry lasts would change meaning on its own. The cost is one question after a rename, and nothing is lost: calls on B all come after B took its name. This replaces "follows a rename" in the usage contract. |
| 5 | A branch snapshot with `declared_at: null` attributes nothing. | Such a snapshot comes only from keys set by hand or a declaration cut short: every release wrote `aiddDeclaredAt`. |
| 6 | M8: the doc is wrong, not the hook. The gate blocks every prompt on an undeclared working branch until it is declared (or declared `--none`). A declared branch is never asked again, unless a copy or rename onto it changes its keys. | The spec that introduced the gate (`2026_10_09_deterministic-tokens/spec.md`) requires asking "before any token of that work is spent", and its target (`context/target.md`, "When to ask") re-checks every prompt. "Once" meant "remembered once answered". Letting later prompts through would spend tokens unattributed and need per-branch state. |
| 7 | A branch with any declaration key listed twice is undeclared, and the command replaces every value of a key (`--replace-all`, `--unset-all`). Added after review rounds 1 and 2. | `git branch -C` or `-M` onto an existing branch keeps both sets of keys: reading the last value of each mixed the copied declaration into the branch's own (a ticket or task the own one never set), and a plain `git config` set refuses a key with two values, so declaring again failed. |
| 8 | A branch's start is the newest reflog entry where it took its name by a copy or a rename (git's `Branch: renamed|copied`, libgit2's `branch: renamed`), else its oldest entry. Added after review rounds 3 and 4. | A copy or a rename brings the source's reflog, so the oldest entry is the source's creation. On a name used before, that made the older branch's declaration look newer than the one made after the copy, and the report kept it while `status` and the gate showed the new one. |

## Limits (stated, not fixed)

| Limit | Why it stays |
| --- | --- |
| Hand edits of the keys declare a branch: every key including the stamp, `aiddTask` and `aiddDeclaredAt` alone (read as unstamped), or a stamp removed or emptied. | git config is the person's own; no reader can tell who wrote a key. |
| A copy or a rename of an unstamped (legacy) declaration is inherited until the new name is declared again. | Decision 3. |
| Calls on a name used before, after a copy, rename or recreation and before it is declared again, still go to the earlier generation's declaration in the report. | A branch with no declaration takes no snapshot; only calls the gate did not hold can land there. Usage contract, residual limits. |
| A rename or copy entry expired from the reflog before the first declaration: the oldest remaining entry is the start. | Same class as any reflog expiry. Usage contract, residual limits. |
| Snapshots recorded before this change (a copied branch, a hand-set task with a `declared_at`) keep attributing. | Attribution reads only snapshots; `branches.jsonl` is append-only and its format is unchanged. |

## Acceptance

| # | Criterion |
| --- | --- |
| 1 | Sandboxed e2e: declare on A, `git branch -c A B`, calls on B: `report --axis ticket --json` does not give B's calls A's ticket. |
| 2 | Sandboxed e2e: `aiddTask` set by hand without `aiddDeclaredAt`: the branch's calls are `no-binding`. |
| 3 | Sandboxed e2e: after `git branch -m A B`, B's calls are `no-binding`; declaring on B attributes them. |
| 4 | Sandboxed e2e: an unstamped declaration with `aiddDeclaredAt` still attributes. |
| 5 | The hook applies the same rule, tested in `scripts/__tests__/` through real git, from the shared fixture both readers run. |
| 6 | `forget` removes `aiddDeclaredFor`. |
| 7 | Each guard is shown by a mutation that turns its test red. |
| 8 | The usage contract, the CLI telemetry memory, the plugin README and skill say decisions 1 to 6 and the limits. |
Loading
Loading