# FREEZE — musehub#216 BranchMeta created_by/created_at provenance (Thinking) ```yaml id: muse-216-branchmeta-created-by model: Thinking frozen: true status: freeze-review-pass date: 2026-09-17 author: aaronrene repo: gabriel/muse ticket: https://staging.musehub.ai/gabriel/musehub/issues/216 consumes: - gabriel comment sha256:d3640c5d4e7ebf437dc9862e46af3cd96d687a4a9b5ec7e558502c077cb4e8fc - docs/MUSE-215-CREATED-BY-FALLBACK-FREEZE.md (out_of_scope for #216; do not redesign) escalation: none tier3_gates: - merge to gabriel/muse main (Gabriel review) blocked_by: freeze review must be pass before {step}b Auto out_of_scope: - "changing _BranchEntryJson.created_by type or meaning (stays tip-authorship str | None per #215)" - "changing _created_by_from_record or the list-JSON tip assignment" - "changing committed_at (stays the tip timestamp)" - "writing inferred creation records into TOML (no backfill writes)" - "muse push / fetch / clone / MuseHub wire types" - "shelf _resolve_created_by / shelf handle+kind JSON" - "adding --agent-id to muse branch / switch / checkout" - "git-import / update-ref / rebase / migrate plumbing" - "muse init initial main (no create-site stamp; list keys stay JSON null)" - "human (non --json) muse branch listing text" - "task/video-timeline-vid1 and untracked timeline trees" ``` ## 1. WHAT is frozen **Statement of truth `{step}b` Auto consumes without re-deriving:** `muse branch --json`'s `created_by` field is **tip-commit authorship** (#215). There is **no persisted branch-creation record**. `BranchMeta` (`muse/cli/config.py:121–139`) stores only `intent`, `resumable`, `remote`, `merge`. `_load_config` (`config.py:269–288`) and `_dump_toml` (`config.py:431–452`) round-trip those four keys only — any other TOML key under `[branch.""]` is dropped on the next write. `write_branch_meta` (`config.py:1002–1033`) is called **only** when `--intent` or `--resumable` is supplied (`branch.py:800–807`, `checkout.py:1026–1031`). A plain `muse branch feat/x` / `muse switch -c feat/x` / `muse checkout -b` writes a ref and a reflog line and **does not** write `BranchMeta`. Gabriel's live reproduction (issue #216 comment `sha256:d3640c5d…`): create as human → later agent commit on the same branch → list `created_by` flips. After #215 the human tip is no longer JSON `null`, but the value is still **whoever authored the current tip**, not who created the branch. `committed_at` is the same class of fact (tip time, not create time). Reflog (`muse/core/reflog.py:19–32`) records ref motion with a free-form `author` label (often `"user"` at `checkout.py:1023`) and is **not** a durable identity record. Do not treat reflog as the source of truth. ### Frozen distinction (two facts) | Fact | JSON key | Type | Source | Mutability | | --- | --- | --- | --- | --- | | Tip authorship | `created_by` | `str \| None` | `_created_by_from_record(tip)` (#215) | Changes when the tip changes | | Tip timestamp | `committed_at` | `str \| None` | `CommitRecord.committed_at` | Changes when the tip changes | | Branch creator | `branch_created_by` | `{handle: str, kind: "human"\|"agent"} \| null` | `BranchMeta` | Write-once at create | | Branch created-at | `branch_created_at` | `str \| None` (ISO-8601 UTC) | `BranchMeta` | Write-once at create | `#215` JSON type is **not** redesigned. The shelf object shape (`shelf.py:124–128`) is used for the **new** `branch_created_by` field only. ### Frozen persistence (TOML) Under `[branch.""]` in `.muse/config.toml`, three new optional scalars: ```toml [branch."feat/example"] created_by = "aaronrene" # sanitized handle created_by_kind = "human" # "human" | "agent" created_at = "2026-09-17T10:54:10.123456+00:00" ``` TOML uses scalars because `_dump_toml` has no nested-table writer (`config.py:341–454`). JSON maps `created_by` + `created_by_kind` → `{"handle", "kind"}`. The TOML key `created_by` is **not** the list-JSON key `created_by`. `created_at` is `now_utc_iso()` (`muse/core/types.py:309–319`) at the moment of first create. Timezone-aware UTC required. ### Frozen identity at create `_resolve_branch_creator(root) -> tuple[str, Literal["human","agent"]] | None` lives next to `write_branch_meta` in `muse/cli/config.py` (not in `shelf.py`). ``` 1. raw = os.environ.get("MUSE_AGENT_ID", "") if str(raw).strip(): handle = sanitize_provenance(raw.strip()[:256]) if handle: return (handle, "agent") 2. raw = get_config_value("user.handle", root) # same source muse commit uses if raw and str(raw).strip(): handle = sanitize_provenance(str(raw).strip()[:256]) if handle: return (handle, "human") 3. return None ``` Do **not** invent the sentinel handle `"human"` (shelf does that at `shelf.py:489`; branch provenance must not). When the helper returns `None`, still write `created_at`. Omit `created_by` / `created_by_kind` rather than storing an empty handle. ### Frozen write-once If the existing `BranchMeta` entry already has a non-empty `created_at`, ignore new `created_by` / `created_by_kind` / `created_at` kwargs. Intent / resumable / remote / merge updates must preserve the creation triple. `muse switch -C` on an **existing** branch (`switch.py:554` `branch_existed is True`) is a reset, not a create — do not restamp. `muse checkout -b` refuses if the ref exists (`checkout.py:995–1001`); there is no `checkout -B`. ### Frozen create sites (must stamp on first ref create) | Porcelain | File today | Rule | | --- | --- | --- | | `muse branch [start]` | `branch.py:739–823` | Stamp even when `--intent`/`--resumable` are absent | | `muse checkout -b` / `muse switch -c` | `checkout.py:1015–1031` (switch delegates) | Stamp on the create path | | `muse switch -C` when the ref did **not** exist | `switch.py:513–554` (`branch_existed is False`) | Stamp; if `branch_existed` skip | | `muse worktree add … -b NEW` | `worktree.py:185–211` | Stamp after `write_branch_ref` | | `muse branch -c/-C` dest | `branch.py:690–737` | Dest is a **new** creation; stamp dest; do not copy source `created_*`. Force-copy onto an existing dest: delete dest's old meta first, then stamp (dest identity is replaced) | | `muse init` initial `main` | `init.py` | **Not a stamp site.** List `branch_created_*` stay JSON `null` (F4) | ### Frozen rename `muse branch -m/-M` (`branch.py:634–685`) currently moves the ref and **does not** move `BranchMeta` (intent is already orphaned). `{step}b` **must** move the entire `BranchMeta` dict from the old key to the new key and delete the old key. Force-rename onto a name that already has meta: source identity wins (the dest branch is destroyed). This is the same branch's write-once record moving with the name. ### Frozen delete / prune `delete_branch_meta` (`config.py:1035–1051`) already drops the section. Keep that on `-d/-D` and `--prune-config`. No extra work. ### Frozen list JSON (additive keys only) `_BranchEntryJson` (`branch.py:97–105`) keeps `created_by: str | None`. Add a list-local TypedDict matching shelf's shape (`shelf.py:124–128`) without importing shelf: ```python class _BranchCreatedBy(TypedDict): handle: str kind: str # "human" | "agent" # on _BranchEntryJson: branch_created_by: _BranchCreatedBy | None branch_created_at: str | None ``` Read path: `meta = read_branch_meta(...)`. - `branch_created_at`: if TOML `created_at` is a non-empty str, parse with `datetime.fromisoformat` (accept the `+00:00` form `now_utc_iso` writes). Require `tzinfo is not None`. On `ValueError`, missing tz, or empty → JSON `null`. - `branch_created_by`: emit `{"handle", "kind"}` only when **both** (a) TOML `created_by` sanitizes to a non-empty handle **and** (b) `created_by_kind` is exactly `"human"` or `"agent"`. Empty handle, missing kind, or invalid kind → JSON `null` even if the other half is present. Do not call `_created_by_from_record` for these keys. Legacy branches with no creation triple: both new keys are JSON `null`. `created_by` (tip) continues to follow #215. That is the **read-side legacy shim**. Do **not** write tip authorship into TOML. Add `branch_created_by` and `branch_created_at` to `_LIST_REQUIRED_KEYS` in `tests/test_branch_json_schema.py` (today `:40–43`). Human listing (`branch.py:921+`) is unchanged. ### Frozen backfill decision **No TOML backfill in `{step}b`.** Inferring creator from tip or reflog and persisting it would write a lie (Gabriel's own reproduction: tip ≠ creator). A one-shot inferred write is irreversible pollution of a write-once field. Existing branches stay `branch_created_* = null` until a future, separately frozen, operator-gated backfill exists. `{step}b` does not add `muse branch --backfill-created-meta`. ### Frozen hub-wire decision **Local-only, matching `intent` / `resumable`.** Evidence: - `.muse/config.toml` is repo metadata, not snapshot content. `muse clone` writes `_DEFAULT_CONFIG` (`clone.py:170`) and does not copy the source repo's `[branch.*]` table. - No push/fetch path serializes `BranchMeta`. `{step}b` does **not** change `muse push`, fetch, clone, or MuseHub wire types. Hub-visible creation provenance is a **follow-on ticket**, not this phase. Creation metadata therefore does not survive clone today — same as intent. Document that in the `{step}b` helper docstring so Auto does not "fix" it by inventing a wire field. ## 2. HOW is frozen **Allowed files:** - `muse/cli/config.py` — `BranchMeta` fields; `_load_config` parse; `_dump_toml` emit; `write_branch_meta` kwargs + write-once; `_resolve_branch_creator`; `stamp_branch_created`; `move_branch_meta`. - `muse/cli/commands/branch.py` — stamp on create; stamp dest on copy; move meta on rename; list JSON additive keys. Do **not** change `_created_by_from_record` (`branch.py:144–174`) or the tip assignment (`branch.py:906`). - `muse/cli/commands/checkout.py` — stamp on `-b` create path. - `muse/cli/commands/switch.py` — stamp on `-C` only when `not branch_existed`. - `muse/cli/commands/worktree.py` — stamp on `-b`. - Tests: `tests/test_cmd_branch.py`, `tests/test_branch_json_schema.py`, `tests/test_branch_intent_created_by.py`, and/or a new `tests/test_branch_created_meta.py`. - This freeze + `docs/MUSE-ROADMAP.md` + `docs/MUSE-OVERSEER-HANDOVER.md`. **Forbidden:** - Changing `_BranchEntryJson.created_by` from `str | None` to an object. - Calling `shelf._resolve_created_by`. - Writing `created_*` into TOML from tip `CommitRecord` or reflog. - Touching push/clone/wire/transport. - Touching `task/video-timeline-vid1` or untracked timeline trees. **Helpers `{step}b` should extract (names may vary; behavior may not):** - `stamp_branch_created(root, name)` → resolve creator, `now_utc_iso()`, `write_branch_meta` with write-once. Call **after** the ref file exists (post-`write_branch_ref` or empty-ref write). Skip on `--dry-run`. If the TOML write raises, propagate — do not swallow, do not invent a handle, do not delete the ref. - `move_branch_meta(root, old, new)` → copy dict, delete old key. - `_branch_created_from_meta(meta) -> tuple[dict|None, str|None]` for list JSON. ## 3. Fail-closed rules | # | Condition | Result | | --- | --- | --- | | F1 | New branch create, identity resolves | TOML has sanitized handle + kind + `created_at`; list JSON both new keys populated | | F2 | New branch create, identity is `None` | TOML has `created_at` only; `branch_created_by` JSON `null`; `branch_created_at` set | | F3 | Existing `created_at` present, later intent/resumable/`-C` reset | Creation triple unchanged | | F4 | Legacy branch, no creation triple | `branch_created_by` and `branch_created_at` JSON `null`; tip `created_by` still #215 | | F5 | TOML `created_by_kind` not exactly `human` or `agent` | `branch_created_by` JSON `null` (do not crash listing) | | F6 | TOML `created_at` unparseable / missing timezone | `branch_created_at` JSON `null` | | F7 | Handle with C0/C1/ESC | `sanitize_provenance`; if nothing remains, omit TOML handle (F2) | | F8 | Tip commit changes after create | `created_by` (tip) may change; `branch_created_*` must not | | F9 | Rename | Meta moves with the name; list under the new name shows the original triple | | F10 | Copy | Dest has a **new** triple (copier's identity + now); source triple unchanged | | F11 | Delete | `[branch."name"]` removed; no leftover keys | | F12 | Exception reading config.toml | keep existing list/create error behavior; do not invent a handle | | F13 | TOML injection in handle (`"`, `\`, newline) | `_escape` (`config.py:301–314`); file remains parseable | | F14 | Existing `--dry-run` paths (`checkout -b`, `switch -C`) | no `config.toml` write. Do not add new `--dry-run` flags | | F15 | Force-copy onto existing dest | dest's previous `[branch."dest"]` deleted, then new triple stamped | No new network calls. No new top-level config keys outside `[branch.""]`. ## 4. Security - Display + local TOML only. Source is env `MUSE_AGENT_ID` (already used by `muse commit` at `commit.py:437–438`) or `user.handle` via `get_config_value` (`config.py:701–714`). - `sanitize_provenance` (`validation.py:402–428`) on every handle before TOML write **and** JSON emit. Cap 256 chars (`commit.py:136`). - `created_by_kind` allowlist `"human"|"agent"` — no free-form kind. - `_escape` + `_validate_toml_key` already on branch names (`config.py:321–339`). Handles go through `_escape` as values. - Do not log `MUSE_AGENT_KEY`, `MUSE_AGENT_HD_SEED`, identity.toml, or full `config.toml` in tests or CLI output. - No auth change, no wire change. Escalation: **none**. - Write-once prevents a later compromised `MUSE_AGENT_ID` from rewriting historical creator on an existing branch. ## 5. Seven-tier test matrix | Tier | Proves | How | | --- | --- | --- | | 1 unit | `_resolve_branch_creator`: agent env wins; handle from `user.handle`; both empty → `None`; whitespace-only env → human path; ESC-only → `None`. `write_branch_meta` write-once: second stamp does not change `created_at`. `_load_config`/`_dump_toml` round-trip the three new keys and still preserve `intent`/`remote`. `move_branch_meta` relocates the dict. Invalid kind dropped on read helper. | `tests/test_cmd_branch.py` and/or `tests/test_branch_created_meta.py` | | 2 integration | `muse branch feat/x` (no `--intent`) then `muse branch --json`: that entry has non-null `branch_created_at`; `created_by` still equals tip authorship (#215). `tomllib` shows `created_at` under `[branch."feat/x"]`. | CliRunner fixture repo | | 3 e2e | Create as human (no `MUSE_AGENT_ID`, mocked `user.handle="aaronrene"`) → `branch_created_by == {"handle":"aaronrene","kind":"human"}`. Then `commit --agent-id claude-code` on that branch → tip `created_by == "claude-code"` **and** `branch_created_by` still human. Copy dest gets a new `created_at` ≥ source. Rename: triple follows the new name. | same module | | 4 stress | 200 `muse branch` creates; every list entry has `branch_created_by` and `branch_created_at` keys; no OOM | cap runtime | | 5 data-integrity | Stamp twice (intent update after create) → identical `created_at` bytes. List twice on unchanged repo → identical `branch_created_*`. Corrupt kind in TOML → JSON `null` for object, listing still exit 0. Delete removes the section. | assert both | | 6 performance | 200-branch list < 5s on the fixture (same bound as #215 tier 6) | wall clock | | 7 security | ESC in `MUSE_AGENT_ID` does not appear in TOML or JSON; no `MUSE_AGENT_KEY` / `MUSE_*` key material in output; handle containing `"` round-trips via `_escape` without breaking TOML parse | `sanitize_provenance` + `tomllib.load` | Existing `TestListSchemaI` and `TestCreatedByAuthorFallback215` must keep passing. Adding the two new keys to `_LIST_REQUIRED_KEYS` is required so a future regression cannot drop them. ## 6. Definition of Done (for `{step}b`, not this freeze) - All seven tiers green locally. - `/build-verification-review` → **pass**. - `#215` tip-authorship JSON unchanged (type and helper). - No push/clone/wire edits. - Living docs updated together. - Feature-branch commit on `feat/216-branchmeta-created-by`. - No `main` merge. ## 7. Review discipline (file+line citations) `ok review --freeze docs/MUSE-216-BRANCHMETA-CREATED-BY-FREEZE.md` must return **pass** before any `BranchMeta` write in product code. | Claim | file+line | | --- | --- | | `BranchMeta` has no creation fields | `muse/cli/config.py:121-139` | | `_load_config` drops unknown branch keys | `muse/cli/config.py:269-288` | | `_dump_toml` emits only intent/resumable/remote/merge | `muse/cli/config.py:431-452` | | `write_branch_meta` kwargs are intent/resumable only | `muse/cli/config.py:1002-1033` | | Create skips meta unless intent/resumable | `muse/cli/commands/branch.py:800-807` | | List `created_by` is tip helper | `muse/cli/commands/branch.py:906` | | JSON type `created_by: str \| None` | `muse/cli/commands/branch.py:105` | | Checkout `-b` meta only if intent/resumable | `muse/cli/commands/checkout.py:1026-1031` | | Worktree `-b` writes ref, no meta | `muse/cli/commands/worktree.py:210-211` | | Rename does not move meta | `muse/cli/commands/branch.py:634-685` | | Shelf object shape | `muse/cli/commands/shelf.py:124-128` | | `sanitize_provenance` | `muse/core/validation.py:402` | | `now_utc_iso` | `muse/core/types.py:309` | | Clone writes default config, not source `[branch.*]` | `muse/cli/commands/clone.py:170` | | Commit reads `MUSE_AGENT_ID` the same way | `muse/cli/commands/commit.py:437-438` | | `user.handle` via identity store | `muse/cli/config.py:701-714` | | #215 out_of_scope named this ticket | `docs/MUSE-215-CREATED-BY-FALLBACK-FREEZE.md:18-21` | | `_LIST_REQUIRED_KEYS` (add two keys here) | `tests/test_branch_json_schema.py:40-43` | | `switch -C` `branch_existed` | `muse/cli/commands/switch.py:554` | | checkout `-b` refuses existing ref | `muse/cli/commands/checkout.py:995-1001` | ## Review record | Round | Reviewer | Verdict | Resolution | | --- | --- | --- | --- | | 1 | Freeze-review loop (thinking) | findings | Removed false `checkout -B`; froze ISO parse + split-object null; force-copy restamp; dry-run F14; citation table `_LIST_REQUIRED_KEYS` / switch `-C` / checkout refuse | | 2 | Freeze-review loop (thinking) | findings | Init `main` is not a stamp site; F14 limited to existing dry-run paths (no new flags) | | 3 | Freeze-review loop (thinking) | findings | Stamp after ref exists; propagate TOML I/O; no swallow / no ref rollback | | 4 | Freeze-review loop (thinking) | pass | C1–C8 clean: two facts, write-once, no #215 JSON type change, local-only hub-wire, no backfill writes, citations file+line | ```yaml review_stamp: gate: mechanical reviewed_at: '2026-09-17T11:02:53Z' mechanical_verdict: pass produced_by: checklist_engine provider_kind: rule_engine reviewer_mode: agent reviewer_model: null reviewer_provider: local checklist_ids: - C1 - C2 - C3 - C4 - C5 - C6 - C7 - C8 checklist_source: builtin findings_count: 0 override_applied: false kit_version: 0.1.0 artifact_digest: sha256:ee01f104b71e4636d1f5ffc063553ad4c00c5be39a204a6fc52ffb518c6a845b ```