FREEZE — musehub#216 BranchMeta created_by/created_at provenance (Thinking)
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."<name>"] 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."<name>"] in .muse/config.toml, three new optional scalars:
[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 <name> [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:
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 TOMLcreated_atis a non-empty str, parse withdatetime.fromisoformat(accept the+00:00formnow_utc_isowrites). Requiretzinfo is not None. OnValueError, missing tz, or empty → JSONnull.branch_created_by: emit{"handle", "kind"}only when both (a) TOMLcreated_bysanitizes to a non-empty handle and (b)created_by_kindis exactly"human"or"agent". Empty handle, missing kind, or invalid kind → JSONnulleven 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.tomlis repo metadata, not snapshot content.muse clonewrites_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—BranchMetafields;_load_configparse;_dump_tomlemit;write_branch_metakwargs + 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-bcreate path.muse/cli/commands/switch.py— stamp on-Conly whennot 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 newtests/test_branch_created_meta.py. - This freeze +
docs/MUSE-ROADMAP.md+docs/MUSE-OVERSEER-HANDOVER.md.
Forbidden:
- Changing
_BranchEntryJson.created_byfromstr | Noneto an object. - Calling
shelf._resolve_created_by. - Writing
created_*into TOML from tipCommitRecordor reflog. - Touching push/clone/wire/transport.
- Touching
task/video-timeline-vid1or 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_metawith write-once. Call after the ref file exists (post-write_branch_refor 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."<name>"].
4. Security
- Display + local TOML only. Source is env
MUSE_AGENT_ID(already used bymuse commitatcommit.py:437–438) oruser.handleviaget_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_kindallowlist"human"|"agent"— no free-form kind._escape+_validate_toml_keyalready on branch names (config.py:321–339). Handles go through_escapeas values.- Do not log
MUSE_AGENT_KEY,MUSE_AGENT_HD_SEED, identity.toml, or fullconfig.tomlin tests or CLI output. - No auth change, no wire change. Escalation: none.
- Write-once prevents a later compromised
MUSE_AGENT_IDfrom 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.#215tip-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
mainmerge.
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 |
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