gabriel / muse public
MUSE-216-BRANCHMETA-CREATED-BY-FREEZE.md markdown
373 lines 18.9 KB
Raw
sha256:5eff0848db1d0748f3a195ca871a9275e7455d447690411ec2d83434e11baa65 feat(#216): persist write-once BranchMeta creation provenance Human minor ⚠ breaking 3 days ago

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 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.pyBranchMeta 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."<name>"].

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-reviewpass.
  • #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
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
File History 1 commit
sha256:5eff0848db1d0748f3a195ca871a9275e7455d447690411ec2d83434e11baa65 feat(#216): persist write-once BranchMeta creation provenance Human minor 3 days ago