Skip to content

feat(s3): implement object tagging (VersionState second tenant) - #81

Merged
frrist merged 1 commit into
design/s3-object-taggingfrom
frrist/feat/object-tagging
Aug 14, 2026
Merged

feat(s3): implement object tagging (VersionState second tenant)#81
frrist merged 1 commit into
design/s3-object-taggingfrom
frrist/feat/object-tagging

Conversation

@frrist

@frrist frrist commented Aug 13, 2026

Copy link
Copy Markdown
Member

Implements docs/s3-object-tagging.md — stacked on the design PR. Full TestForgeVersity conformance partition green against the smelt stack.

  • Methods (s3frontend/objecttag.go): Get/Put/DeleteObjectTagging over the shared per-version state machinery — the state-target resolution and state-write helpers gain a require-lock flag (lock passes true, tagging false), so nothing is duplicated. Untagged versions answer the empty set; markers 405; the versionId grammar is the versioning design's.
  • Merge rule live both ways: tag writes carry retention/legal-hold verbatim and vice versa; DeleteObjectTagging exercises empty-block elision (the block is removed, the emptied tree drops off the leaf).
  • Creation-time stamping: x-amz-tagging on PutObject parsed via backend.ParseObjectTags before ingest (an invalid header uploads nothing); copy directives (COPY inherits the resolved source version's tags, REPLACE takes the header); multipart carries the raw header on the session (validated at create, migration 00012) and stamps at Complete. All stamping rides the same commit that installs the version.
  • Echo: GET/HEAD gain x-amz-tagging-count from the same single state-block fetch as the lock headers (stateHeaderFields).
  • Unversioned buckets make the discard cleanup real: a null-replacing PUT prunes the old version's tag entry in the same commit — covered by a dedicated unit test.

Conformance: three new categories (PutObjectTagging, GetObjectTagging, DeleteObjectTagging), the eight Versioning_* tagging rows, and promotions for PutObject_tagging, CreateMultipartUpload_with_tagging, CopyObject_should_copy_tagging, CopyObject_should_replace_tagging, GetObject_success, and HeadObject_success (the last two had XFailed on the missing TagCount echo). One new XFail with its reason recorded: DeleteObjectTagging_expected_bucket_owner — the hilt flow substitutes the root access key as ACL owner, so the tenant's correct-owner request 403s (the existing *_expected_owner surface, not a tagging defect).

🤖 Generated with Claude Code

@frrist
frrist requested review from alanshaw and hannahhoward and removed request for hannahhoward August 13, 2026 18:15
@frrist frrist self-assigned this Aug 13, 2026

@hannahhoward hannahhoward 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.

nitpicks but LGTM

Comment thread s3frontend/objectlock.go
// mapCommitError); key existence outranks lock-enabled, which outranks the
// versionId grammar, matching posix and the pinning conformance cases.
func (b *Backend) mutateVersionState(ctx context.Context, bucketName, key, versionID string, mutate func(*msbucket.VersionState)) error {
func (b *Backend) mutateVersionState(ctx context.Context, bucketName, key, versionID string, requireLock bool, mutate func(*msbucket.VersionState)) error {

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.

might be worth two convenience functions mutateVersionLock and mutateVersionTags that set the boolean automatically, just cause it'd be more clear.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in c0fd7c5, using your names. The read-side helper had the same bare boolean, so gave it the same treatment ( resolveLockTarget / resolveTagTarget ). No call site passes a raw true/false anymore.

Comment thread s3frontend/objecttag.go Outdated
// withTags merges a creation-time tag set into a version's initial state,
// allocating the state when only tags are present (docs/s3-object-tagging.md
// §4). Returns vs unchanged when there are no tags.
func withTags(vs *msbucket.VersionState, tags map[string]string) *msbucket.VersionState {

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.

I may be being pedantic, but I don't like withTags as a name for this function. Usually I'd consider a "with x" function to return a value without modifying the input. This one modifys vs is tags are present and its not nil. My suggestion, more wordy, is "applyTagsIfPresent" -- the actual behavior is catpured in the function name then.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

renamed to applyTagsIfPresent in c0fd7c5 and updated the code comment

frrist added a commit that referenced this pull request Aug 14, 2026
…Present

Per review on #81: the require-lock boolean disappears from call sites
behind four thin wrappers — mutateVersionLock / mutateVersionTags and
resolveLockTarget / resolveTagTarget — so each operation family sets its
own gate and a bare true/false never appears at a call site. withTags is
renamed applyTagsIfPresent: the old name implied a non-mutating derive
(the Go WithX convention) while the function mutates its argument in
place; the new name and its comment state the mutation outright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements docs/s3-object-tagging.md. Tags live in the per-key
version-state tree the object-lock design built: the three per-version
methods run the shared check order with no bucket gate (the state-target
and state-write helpers gain a require-lock flag), the merge rule carries
lock fields across tag writes and tags across lock writes, and
DeleteObjectTagging exercises the empty-block elision rule for real. A
version without tags answers the empty set, never a sentinel.

Creation-time stamping covers the x-amz-tagging header on PutObject
(parsed via backend.ParseObjectTags before ingest, so an invalid header
uploads nothing), the copy tagging directive (COPY inherits the resolved
source version's tags, REPLACE takes the request header), and the
multipart carry (the raw header validated at create, stored on the
session, stamped at Complete). GET/HEAD gain the x-amz-tagging-count
echo from the same state-block fetch as the lock headers. Tags on
unversioned buckets make the write-rule discard cleanup live: a
null-replacing PUT prunes the old version's tag entry in the same
commit.

itest gains the three tagging categories and the eight Versioning_*
tagging rows, and promotes PutObject_tagging,
CreateMultipartUpload_with_tagging, CopyObject_should_copy_tagging,
CopyObject_should_replace_tagging, GetObject_success, and
HeadObject_success (the last two XFailed on the TagCount echo).
DeleteObjectTagging_expected_bucket_owner is XFail: the hilt flow
substitutes the root access key as the ACL owner, so the tenant's
correct-owner request 403s — the existing *_expected_owner surface.

Per review, the require-lock boolean never appears at a call site: the
named wrappers mutateVersionLock / mutateVersionTags and
resolveLockTarget / resolveTagTarget set the gate for their operation
family, and applyTagsIfPresent (previously withTags) states its in-place
mutation outright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frrist
frrist force-pushed the frrist/feat/object-tagging branch from c0fd7c5 to 62258fc Compare August 14, 2026 00:56
@frrist
frrist merged commit 1daf2d0 into main Aug 14, 2026
12 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants