Skip to content

feat: preserve artifact identity through publishing - #407

Merged
StpMax merged 4 commits into
stagingfrom
artifact-collaboration-workflow
Aug 28, 2026
Merged

feat: preserve artifact identity through publishing#407
StpMax merged 4 commits into
stagingfrom
artifact-collaboration-workflow

Conversation

@StpMax

@StpMax StpMax commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • One canonical artifact identity: metadata.json keeps a single 32-hex UUID id (uuid4().hex for new artifacts; the folder slug uses id[: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 stored stableId as the id so already-minted publish/comment/auth keys never orphan.
  • The publish payload carries artifact_key: "artifact/<uuid>" derived from that id, so all published versions, comments and access rules group under one key.
  • The .revisions/ journal directory is reserved and excluded from published bundles, file listings and content-mtime (_FULLSTACK_EXCLUDED, _BUNDLE_SKIP_NAMES, _HOUSEKEEPING_DIRS/_FILES).

History: f4504ac1 introduced the identity as a second stableId field; acbb74a9 collapsed the pair into the single id described above. Includes a merge of origin/staging (conflict in publisher.py resolved 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 id in its metadata (legacy 8-char ids are deterministically widened, keeping the old value as prefix so folder slugs keep resolving) — published as artifact_key = "artifact/<uuid>", the key that ties together drafts, published versions, revisions, comments and access rules. Roles are owner (can edit, resolve comments, dispatch agent repairs) and reviewer (can view and comment).

🤖 Generated with Claude Code

ianu82 and others added 3 commits August 25, 2026 22:03
…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 stableId when 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.

Comment thread anton/publisher.py Outdated
Comment thread anton/core/artifacts/models.py Outdated
Comment on lines +85 to +89
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread anton/publish_access.py Outdated
Comment on lines +21 to +24
# 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"}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread anton/publisher.py
Comment on lines 454 to 458
payload_dict["state_manifest"] = state_manifest
if missing:
payload_dict["missing_datasources"] = missing
artifact_key = artifact_key or artifact_key_for(artifact.id)
else:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lucas-koontz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@StpMax
StpMax merged commit 61ec5db into staging Aug 28, 2026
12 checks passed
@StpMax
StpMax deleted the artifact-collaboration-workflow branch August 28, 2026 13:21
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants