feat: preserve artifact identity through publishing - #407
Conversation
…tion-workflow # Conflicts: # anton/publisher.py
An artifact carried two identities: `id`, eight hex characters wide and
baked into the folder slug, and `stableId`, a full UUID that keyed the
published versions, auth rules, revisions and comment threads. Two
fields meant two chances to disagree about which artifact this is.
Widen `id` to the full UUID and drop `stableId`. New artifacts get
`uuid4().hex`; the slug keeps a readable suffix by carrying `id[:8]`.
A legacy eight-character id widens deterministically:
id = old_id + uuid5(ns, f"{old_id}:{createdAt}").hex[8:]
The old characters stay the prefix, so folders already named
`<name>-<id[:8]>` keep addressing the same artifact. The 24-character
tail is derived, never random: anton widens in memory only while
cowork-server persists, so both have to reach the same value without
coordinating — a random tail would let whoever touched the artifact
first mint its identity and fork the comment threads.
Precedence, when both fields are present on disk:
* an `id` that already parses as a UUID wins outright, so a stale
`stableId` written by an older build cannot re-stamp an identity
that published versions are already keyed under;
* otherwise `stableId` decides — it already keyed those things, and
keeping them bound is worth more than the slug's readable suffix.
Anything else in `id` is widened rather than rejected: hand-written and
very old records carry names there (`"static-art"`), and refusing them
would drop the artifact from every listing, which reads as a deletion.
The one shape that raises is a value that plausibly IS a damaged
identity — hex-only and wider than a legacy id — because re-minting
that would silently detach the artifact from its published versions.
`id` is now constrained to 32 lowercase hex at the field, so a record
the widening validator could not widen is rejected at the metadata
boundary instead of surfacing later as `UUID('')` inside `artifact_key`.
`artifact_key()` moves here from cowork-server's side of the fence and
returns the canonical DASHED spelling — the same normalization the
upload lambda applies to what it stores in `_meta.json`, so both ends of
the comments API agree. The external `artifact/<uuid>` format does not
change; only which field feeds it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR standardizes artifact identity to a single canonical 32-hex UUID (metadata.json.id) and ensures legacy artifacts deterministically widen to that identity in-memory, so published versions/comments/access rules can remain consistently keyed across renames and republish events. It also reserves .revisions/ as a private journal directory that must never be surfaced in listings or published bundles.
Changes:
- Replace short 8-hex artifact IDs with canonical 32-hex UUIDs, including deterministic widening logic for legacy metadata (and adoption of persisted
stableIdwhen present). - Introduce/propagate
artifact_key = "artifact/<uuid>"for publish payloads, and export related helpers from the artifacts package. - Exclude
.revisions/from publish bundles and artifact file reconciliation, with new tests enforcing the exclusion.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_publish_revision_exclusion.py | Adds a regression test to ensure .revisions/ is never bundled in directory publishes. |
| tests/test_artifacts.py | Updates ID/slug expectations and adds coverage for legacy widening, stableId adoption, and canonical artifact_key formatting. |
| anton/publisher.py | Excludes .revisions/ from zips and adds an artifact_key field to the publish payload. |
| anton/publish_access.py | Updates housekeeping exclusion to treat .revisions/ as non-user content. |
| anton/core/artifacts/store.py | Moves artifact IDs to 32-hex UUIDs and excludes .revisions/ from reconciled file listings. |
| anton/core/artifacts/models.py | Adds canonical ID helpers, deterministic legacy widening, and artifact_key(); widens IDs via a pre-validation model validator. |
| anton/core/artifacts/init.py | Exports new identity helpers/constants for external use. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| An `id` that already parses wins outright. That makes a stale `stableId` | ||
| written by an older build inert, instead of letting it re-stamp an identity | ||
| that published versions are already keyed under. Only when `id` is still the | ||
| short form does `stableId` decide — there it already keyed those things, and | ||
| keeping them bound is worth more than the folder slug's readable suffix. |
There was a problem hiding this comment.
Correct, and the docstring is the wrong side. stableId decides whenever id fails to parse — the short legacy form, a name, and a damaged value, since the _DAMAGED_ID_RE raise sits after the stableId branch.
That ordering is intentional: a stored stableId is already what published versions, auth rules and comment threads are keyed under, so it beats both re-deriving a value nothing is keyed under and refusing to load the record. Docstring updated to say that.
| # Keep in sync with anton.publisher._FULLSTACK_EXCLUDED (publisher.py:42): | ||
| # backend.log is the running backend's runtime log — excluded from the | ||
| # published bundle there, so it must not count as user content here either. | ||
| _HOUSEKEEPING_FILES = {"metadata.json", "README.md", "backend.log", ".published.json"} | ||
| _HOUSEKEEPING_FILES = {"metadata.json", "README.md", "backend.log", ".published.json", ".revisions"} |
There was a problem hiding this comment.
Fixed. Dropped the line-number cross-reference (it drifts on every edit — it was already off by six lines) and kept the name-only pointer, plus a note that the set is matched against the artifact-relative path's first component, which is why a reserved directory like .revisions belongs in it.
| payload_dict["state_manifest"] = state_manifest | ||
| if missing: | ||
| payload_dict["missing_datasources"] = missing | ||
| artifact_key = artifact_key or artifact_key_for(artifact.id) | ||
| else: |
There was a problem hiding this comment.
Narrowing this one: cowork-server passes artifact_key explicitly (cowork/services/publish.py, derived from the artifact folder's own metadata.json), and that is the product path for static artifacts. So the gap is a direct anton publish of a static artifact, not a published-from-Cowork one — smaller than it looks, and worth fixing at that size rather than reworking how the key gets derived.
There was a problem hiding this comment.
Fixed at the size @lucas-koontz scoped it to. publish() now derives the key from the metadata.json next to the primary file when the caller passed none, so a direct anton publish of a static artifact carries it too. Only the artifact root is consulted, never an ancestor: publishing one nested page would otherwise mint a second report under the same key, and the auth rule html_upload upserts is per key — the two reports would fight over it. Covered in tests/test_publish_artifact_key.py (static sends the key, explicit key wins, loose file and nested page send none).
Worth flagging, because this gap was masking a sharper one on the other side of the contract: html_upload._resolve_artifact_key() falls back to {user_dir}/{report_id}, that fallback was stamped into _meta.json unconditionally, and the immutability check only compared the two strings. So a report first published without a key — an older publisher, or anything published in the services-before-anton/cowork-server window of the deploy order — would fail its next publish from a current client with artifact_key cannot change after first publish, which the handler turns into a 500. It also silently voided the "old artifacts move to a stable key on their next publish" migration. Fixing this comment alone would have made that live for static artifacts too.
Fixed in mindshub_services#195: only a stable artifact/<uuid> is persisted into meta (every consumer — viewer, artifact_gateway, delete_report — already derives the legacy composite when the field is absent), and a stored legacy key is adopted rather than defended, so metas already stamped in staging recover on their next publish. _sync_artifact_access() already re-points the auth rule and drops the legacy alias in the same publish.
lucas-koontz
left a comment
There was a problem hiding this comment.
Read the whole diff at acbb74a9. Deriving the widening from id + createdAt so anton and cowork-server land on the same value without coordinating is the right call, and the test that checks both derivations agree is the one I'd have asked for.
No findings of my own. I agree with three of Copilot's four: the docstring/implementation mismatch in resolve_artifact_id is real, the publisher.py:42 cross-reference is stale, and the zip arcname point is fair even though that line predates this PR. The fourth I'd narrow — replied on it.
Merge order: this needs to land before cowork-server#397, which pins it by SHA.
Review of #407. `publish()` derived `artifact_key` only for fullstack artifacts, but a static artifact publishes its primary *file*, so the folder holding metadata.json was never read and the payload carried no key. The upload lambda then locked the report to the legacy `{user_dir}/{report_id}` key and the artifact lost the identity its draft, comment threads and access rule are grouped under. cowork-server passes the key explicitly, so this only ever hit a direct anton publish. Only the artifact root is consulted, never an ancestor: deriving from one would let a nested page mint a second report under the same key, and the auth rule html_upload upserts is per key. Also from the review, both no-ops: - `resolve_artifact_id`'s docstring claimed `stableId` decides only when `id` is the short legacy form; it decides whenever `id` fails to parse, since the damaged-id raise sits after that branch. The behaviour is what we want (a stored `stableId` already keys published versions), so the docstring moved, not the code. - Dropped the line-number cross-reference from `_HOUSEKEEPING_FILES` (it had already drifted six lines) and said instead that the set matches the first path component, which is why `.revisions` belongs in it. - `_zip_html` builds arcnames with `.as_posix()`, matching `_zip_fullstack`. Not a fix: `ZipInfo.__init__` runs `_sanitize_filename()`, which already replaces `os.sep`, so the bundle md5 was never platform-dependent. Co-Authored-By: Claude <noreply@anthropic.com>
Summary
metadata.jsonkeeps a single 32-hex UUIDid(uuid4().hexfor new artifacts; the folder slug usesid[:8]). Legacy 8-char ids are deterministically widened in memory on every load (old_id + uuid5(old_id:createdAt).hex[8:]— the old id stays the prefix, so slug-based folder addressing keeps working; no write-on-read). Records from the short-lived two-field era adopt their storedstableIdas the id so already-minted publish/comment/auth keys never orphan.artifact_key: "artifact/<uuid>"derived from that id, so all published versions, comments and access rules group under one key..revisions/journal directory is reserved and excluded from published bundles, file listings and content-mtime (_FULLSTACK_EXCLUDED,_BUNDLE_SKIP_NAMES,_HOUSEKEEPING_DIRS/_FILES).History:
f4504ac1introduced the identity as a secondstableIdfield;acbb74a9collapsed the pair into the singleiddescribed above. Includes a merge oforigin/staging(conflict inpublisher.pyresolved by unioning the exclusion sets with the anton_state entries).Testing
pytest tests/: 2658 passed, 30 skipped.Related branches (one feature across six repos)
The shared concept: every artifact carries one canonical identity — a 32-hex UUID
idin its metadata (legacy 8-char ids are deterministically widened, keeping the old value as prefix so folder slugs keep resolving) — published asartifact_key = "artifact/<uuid>", the key that ties together drafts, published versions, revisions, comments and access rules. Roles areowner(can edit, resolve comments, dispatch agent repairs) andreviewer(can view and comment).🤖 Generated with Claude Code