Cloud engineer comparing S3 source and copied object metadata, cache headers, and tags at a workstation.

Changing One S3 Metadata Field Can Remove the Others

Wed, Oct 14, 2026

Changing One S3 Metadata Field Can Remove the Others

The change request looks small: copy an S3 object, change its user-defined owner value from qa to release, and leave its contents alone. A developer supplies Metadata={"owner":"release"} with MetadataDirective="REPLACE". The expected destination has the requested owner and identical body bytes, but its schema and dataset entries are gone. The request described a replacement map, not a patch. AWS's copying-objects guidance requires explicitly supplying the other user-configurable metadata that must survive when changing metadata during a copy.

That distinction matters before anyone approves the candidate. A body comparison cannot establish that descriptive fields survived. Neither can checking only the field named in the change request. An approved CacheControl header and an independently controlled tag set belong in the acceptance contract too.

This playbook uses one tiny synthetic source and six fresh destination keys to isolate those questions. It compares default COPY, partial and complete REPLACE requests, an omitted header, an empty metadata map, and a separate tagging change. Each destination must be reconciled through metadata, body, and tag reads, followed by another source check.

The AWS outcomes below are expected, not observed service results. Commissioning generated an offline request plan; it did not contact AWS. Live execution is optional and restricted to an authorized isolated laboratory. Acceptance means agreement with a declared, bounded object contract, not preservation of every S3 property or permission to promote an unverified copy.

Declare the field change and the fields that must survive

Start with two declarations: what may change and what must remain. “Update the owner” answers only the first question. The preservation declaration prevents the implementation from treating every omitted field as disposable.

For this fixture, the complete approved contract is deliberately small:

Dimension

Source baseline

Approved owner-change candidate

User-defined metadata

{"owner":"qa", "schema":"v1", "dataset":"lab"}

{"owner":"release", "schema":"v1", "dataset":"lab"}

ContentType

text/plain

text/plain

CacheControl

max-age=60

max-age=60

Tags

{"stage":"seed"}

{"stage":"seed"}

Body

b"synthetic object body\n"

The same exact bytes

The body contains 22 ASCII bytes, including its final newline. Do not replace the literal with a downloaded object and then call the result an independent expectation. The literal is authored before any upload or copy, so source and destination are each compared against something other than one another.

The metadata field named owner is an application label in this example. It is not the AWS account owner, an ACL entry, or an instruction to transfer object ownership. Similarly, schema=v1 and dataset=lab are declared strings, not promises about an external schema registry or dataset service.

Treat missing fields as differences, even when an application currently tolerates them. This exercise authorizes precisely one metadata-value change. A later decision to remove dataset would require a different reviewed contract, not a weaker assertion in this test.

Place that contract beside the migration request and retain it with the results. Broader cloud application operating practices provide useful context, but the acceptance rule here is concrete: the approved candidate must match every declared dimension, and the retained source must still match its original declaration.

Separate three categories before writing a copy request

The SDK request exposes several fields that sound like “metadata,” but they are not interchangeable. The Metadata dictionary carries user-defined key-value pairs. ContentType and CacheControl are separate parameters for selected system-defined HTTP metadata. The tag set has its own request control and read operation. AWS's object metadata documentation distinguishes these categories and documents lowercase storage of user-defined metadata keys.

Use lowercase ASCII keys and simple ASCII values here. Keep owner, schema, and dataset inside Metadata; keep ContentType and CacheControl outside it. Adding a user-defined entry that happens to be named cachecontrol would not be the request shown in this protocol.

The CopyObject API reference defines separate MetadataDirective and TaggingDirective controls. This separation is why replacing user metadata and replacing tags require distinct assertions.

The body is a fourth dimension. The driver deliberately obtains it through its own read rather than allowing metadata success to stand in for content verification.

Define what this protocol does not preserve

“Complete manifest” means complete for the authored user-defined map, the two named headers, the declared tags, and the literal body. It does not mean every field that a HEAD response might expose.

This is not an ACL-preservation, encryption-key, website-redirect, storage-class, annotation, S3 Metadata table, replication, or lifecycle migration. It excludes version restoration, delete markers, same-key self-copy, multipart uploads, large objects, bulk deletion, and automatic cleanup. Concurrent writers, conditional-write races, and arbitrary-key URL encoding are also outside qualification.

The generated keys use a narrow ASCII pattern. Do not generalize their copy-source construction to arbitrary names. If another application contract depends on an excluded property, stop treating this fixture as sufficient. Extend the reviewed scope and its evidence before approving that different migration.

Generate the plan and run only an authorized isolated laboratory

Save the complete driver below as s3_metadata_fixture.py in a writable disposable directory. Its default invocation creates a local results directory and writes results.json; it prints that file's path, not the JSON contents. No SDK client is constructed in this mode, and no AWS credentials are needed.

python s3_metadata_fixture.py

The local plan contains six case identities, six proposed destination keys, their differing request fields, and their expected manifests. Without live arguments it is not a set of fully bound service requests: the bucket and region can remain unset. That is intentional. It lets reviewers inspect the policy differences before involving an AWS account.

The source code is self-contained. Boto3 and botocore are imported only after live-mode authorization checks. An operator choosing live mode must provide those dependencies through an approved environment and record their actual versions; the offline commissioning versions are not a requirement to install an unreviewed package into production.

The laboratory bucket must already exist, belong to the expected account, be general-purpose, and never have had versioning enabled. The prefix must remain exclusively controlled and quiescent. Argument checks do not establish organizational permission or isolation. The program neither provisions infrastructure nor changes IAM policies to make a failing request succeed.

A fully completed live sequence attempts one source upload and six copies. It retains all resulting objects. Its checks concern this small experiment, not a production rollout, resource inventory, or cleanup service.

"""Default: print a request plan only. --live requires an owned, isolated S3 lab."""

import argparse

import hashlib

import json

import platform

import re

import tempfile

import uuid

from datetime import datetime, timezone

from pathlib import Path

BODY = b"synthetic object body\n"

ORIGINAL = {"owner":"qa", "schema":"v1", "dataset":"lab"}

UPDATED = {"owner":"release", "schema":"v1", "dataset":"lab"}

HEADERS = {"ContentType":"text/plain", "CacheControl":"max-age=60"}

CASES = [

    ("S1_default_copy", {}, ORIGINAL, "max-age=60", "seed", "BASELINE"),

    ("S2_partial_replace", {"MetadataDirective":"REPLACE", "Metadata":{"owner":"release"},

                            **HEADERS}, {"owner":"release"}, "max-age=60", "seed", "REPAIR"),

    ("S3_complete_replace", {"MetadataDirective":"REPLACE", "Metadata":UPDATED, **HEADERS},

                            UPDATED, "max-age=60", "seed", "ACCEPT_BOUNDED"),

    ("S4_omitted_cache_control", {"MetadataDirective":"REPLACE", "Metadata":UPDATED,

                                  "ContentType":"text/plain"}, UPDATED, None, "seed", "REPAIR"),

    ("S5_header_only_replace", {"MetadataDirective":"REPLACE", "Metadata":{}, **HEADERS},

                               {}, "max-age=60", "seed", "REPAIR"),

    ("S6_tags_are_separate", {"MetadataDirective":"COPY", "TaggingDirective":"REPLACE",

                              "Tagging":"stage=review"}, ORIGINAL, "max-age=60", "review", "TAG_CONTROL")

]

def require(ok, message):

    if not ok:

        raise RuntimeError(message)

def main():

    parser = argparse.ArgumentParser()

    parser.add_argument('--live', action='store_true')

    parser.add_argument('--ack-owned-lab', action='store_true')

    parser.add_argument('--bucket')

    parser.add_argument('--region')

    parser.add_argument('--owner', help='12-digit expected AWS account ID')

    args = parser.parse_args()

    destination = Path(tempfile.mkdtemp(prefix='s3-metadata-', dir=Path(__file__).parent))

    prefix = 'metadata-lab-' + uuid.uuid4().hex + '/'

    source = prefix + 'source.txt'

    report = {'revision':'s3-metadata-v1', 'status':'FAIL', 'cases':[],

              'started_utc':datetime.now(timezone.utc).isoformat(),

              'python':platform.python_version(), 'prefix':prefix,

              'source_key':source, 'bucket':args.bucket, 'region':args.region,

              'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),

              'body_sha256':hashlib.sha256(BODY).hexdigest(), 'created_keys':[]}

    try:

        for identity, fields, metadata, cache_control, stage, decision in CASES:

            report['cases'].append({'id':identity, 'status':'EXPECTED_NOT_OBSERVED',

                'destination_key':prefix + identity + '.txt', 'request_fields':fields,

                'expected':{'Metadata':metadata, 'ContentType':'text/plain',

                            'CacheControl':cache_control, 'tags':{'stage':stage}},

                'expected_decision':decision})

        require(len(report['cases']) == len({r['id'] for r in report['cases']}) == 6,

                'Incomplete request plan')

        if not args.live:

            report['status'] = 'PLAN_ONLY_NOT_AWS_EVIDENCE'

            report['aws_calls'] = 0

            return

        require(args.ack_owned_lab and args.bucket and args.region and

                args.owner and re.fullmatch(r'\d{12}', args.owner), 'Missing explicit lab authorization/configuration')

        require(not args.bucket.startswith('arn:') and not args.bucket.endswith('--x-s3'),

                'Use a general-purpose bucket name, not an access point or directory bucket')

        import boto3

        import botocore

        from botocore.config import Config

        from botocore.exceptions import ClientError

        report['boto3'], report['botocore'] = boto3.__version__, botocore.__version__

        client = boto3.client('s3', region_name=args.region,

            config=Config(connect_timeout=5, read_timeout=20, retries={'total_max_attempts':1}))

        common = {'Bucket':args.bucket, 'ExpectedBucketOwner':args.owner}

        versioning = client.get_bucket_versioning(**common)

        require(versioning.get('Status') is None, 'Use a never-versioned disposable lab bucket')

        report['versioning'] = versioning

        def ensure_absent(key):

            try:

                client.head_object(**common, Key=key)

            except ClientError as error:

                require(error.response.get('ResponseMetadata', {}).get('HTTPStatusCode') == 404,

                        'Absence not established: ' + str(error))

            else:

                raise RuntimeError('Refusing to overwrite an existing key')

        def snapshot(key):

            head = client.head_object(**common, Key=key)

            got = client.get_object(**common, Key=key)

            try:

                body = got['Body'].read(len(BODY) + 1)

            finally:

                got['Body'].close()

            require(body == BODY, 'Body differs from independent literal')

            tagged = client.get_object_tagging(**common, Key=key)

            result = {'Metadata':head.get('Metadata', {}), 'ContentType':head.get('ContentType'),

                      'CacheControl':head.get('CacheControl'),

                      'tags':{t['Key']:t['Value'] for t in tagged['TagSet']}}

            return result, {'head':head, 'tags':tagged, 'body_sha256':hashlib.sha256(body).hexdigest()}

        ensure_absent(source)

        report['source_put'] = client.put_object(**common, Key=source, Body=BODY,

                                                Metadata=ORIGINAL, Tagging='stage=seed',

                                                IfNoneMatch='*', **HEADERS)

        report['created_keys'].append(source)

        expected_source = {'Metadata':ORIGINAL, **HEADERS, 'tags':{'stage':'seed'}}

        initial, initial_evidence = snapshot(source)

        report['source_initial'] = initial_evidence

        require(initial == expected_source, 'Source fixture is not the declared baseline')

        for row in report['cases']:

            row['status'] = 'FAIL'

            ensure_absent(row['destination_key'])

            request = {**common, 'Key':row['destination_key'],

                       'CopySource':args.bucket + '/' + source,

                       'ExpectedSourceBucketOwner':args.owner, **row['request_fields']}

            row['request'] = request

            row['copy_response'] = client.copy_object(**request)

            report['created_keys'].append(row['destination_key'])

            require('CopyObjectResult' in row['copy_response'], 'Copy result was not established')

            observed, evidence = snapshot(row['destination_key'])

            row['observed'], row['evidence'] = observed, evidence

            require(observed == row['expected'], 'Copy metadata/header/tag manifest mismatch')

            unchanged, evidence = snapshot(source)

            row['source_after'] = evidence

            require(unchanged == expected_source, 'Source changed during the isolated fixture')

            row['status'] = 'PASS'

        report['status'] = 'PASS_6_AWS_CASES'

    except Exception as error:

        report['error'] = {'type':type(error).__name__, 'message':str(error)}

        raise

    finally:

        report['finished_utc'] = datetime.now(timezone.utc).isoformat()

        path = destination / 'results.json'

        path.write_text(json.dumps(report, indent=2, allow_nan=False, default=str) + '\n')

        print(path)

if name == '__main__':

    main()

Only an authorized operator of the described laboratory should use the optional live command. Replace every placeholder with approved values; do not insert access keys or other credentials into the command or article.

python s3_metadata_fixture.py --live --ack-owned-lab \

  --bucket YOUR-OWNED-LAB-BUCKET --region YOUR-LAB-REGION \

  --owner YOUR-12-DIGIT-ACCOUNT-ID

The administrator must approve permissions for versioning inspection, object reads and writes, tag reads and writes, and the bucket listing access needed for meaningful absence checks. This is an operation checklist, not a complete IAM policy. The actual bucket configuration can introduce additional permission requirements; unresolved access is HOLD, not permission to broaden privileges. Live requests and retained objects may incur charges.

Keep expected outcomes separate from observed AWS evidence

The supplied commissioning record reports PLAN_ONLY_NOT_AWS_EVIDENCE under CPython 3.13.5. It ran from 2026-10-14T08:29:06.007890+00:00 to 2026-10-14T08:29:06.008049+00:00. Separately, seven request shapes, one PutObject and six CopyObject requests, passed offline parameter checks with boto3/botocore 1.43.18. Those are recorded versions, not a latest-version claim. No AWS object, HEAD result, or tag result was observed.

The unchanged driver was independently compiled and executed in its default mode under CPython 3.13.5 on October 14, 2026. That local run again produced six EXPECTED_NOT_OBSERVED cases, zero AWS calls, and an empty created_keys list. Seven request shapes were also independently checked against the installed botocore 1.43.18 model without constructing an SDK client. These local checks establish neither service authorization nor AWS delivery.

For UTF-8 source with LF line endings and a final newline, the driver SHA-256 is f886741136c83b57168fbecb362bfa84999a71bb221e6d856f97ab581bfab7ce. In the report, source_sha256 identifies the Python source file, not the S3 source object. body_sha256 identifies the body literal. Preserve that distinction when reviewing evidence.

The live branch remains a documented, unexecuted integration protocol. Compilation and parameter validation cannot establish authorization, service behavior, or field preservation. The timestamps identify runs, not copy-performance measurements. No fault injection of every error-reporting or local-storage failure path is claimed.

Create and read back the independent source baseline

Before creating the source, the live driver calls GetBucketVersioning. AWS documents an empty versioning configuration for a bucket that has never had versioning enabled; enabled and suspended configurations contain a status. The driver requires that status to be absent. It does not suspend versioning, convert a bucket, or remove old versions.

Next, ensure_absent checks the fresh source key. A successful HEAD means the key already exists and execution stops. A 404 allows the fixture to proceed; a denied or otherwise ambiguous read does not. The HeadObject documentation explains that a missing object can return 403 rather than 404 when the caller lacks s3:ListBucket. Access denial therefore cannot establish absence.

The upload supplies the literal body, all three original metadata entries, both approved headers, and stage=seed. Its IfNoneMatch='*' is the create-only condition documented for PutObject. This source-upload condition does not make the later copy sequence a concurrent-write protocol.

The initial snapshot performs three reads: HEAD for the selected metadata and headers, GET for the body, and GetObjectTagging for tags. It compares the resulting manifest with expected_source. The source must be correct before any destination result can be interpreted.

Keep the account expectation, bucket, region, source key, prefix, and driver digest with the report. These identifiers bind the evidence to the intended experiment without requiring credentials in the record. A bucket name that merely resembles the intended one is not an acceptable substitute.

Do not repair a bad baseline by redefining the expected manifest to match whatever was uploaded. That would reverse the evidence relationship. Investigate the discrepancy and establish the authored baseline in a new authorized run. Until then, later copy cases have no qualified starting point and remain HOLD.

Use default COPY as a positive baseline

S1_default_copy contributes no replacement fields. Its purpose is to establish a positive baseline for copying this source to a separate fresh key. MetadataDirective defaults to COPY, and TaggingDirective separately defaults to COPY in the CopyObject API.

The declared expectation is the original three-entry map, ContentType=text/plain, CacheControl=max-age=60, and {"stage":"seed"}. The retrieved body must equal BODY, and the source must still equal its initial declaration. This is an expected control result until actual reads populate the evidence.

Notice what S1 does not achieve: its owner remains qa. It is a BASELINE, not an accepted owner-change candidate. A migration reviewer who approves it because everything was preserved has missed the requested change, just as a reviewer who checks only the changed owner can miss preservation failures.

Run the baseline before interpreting replacement behavior. If S1 cannot be reconciled, investigate access, the source declaration, and the available evidence rather than declaring that a later partial replacement explains the failure. The six cases are a controlled comparison, not six unrelated opportunities to obtain a green status.

Preserve response semantics without changing the article into an error survey

The Boto3 copy_object reference explains that a copy response can contain an embedded error despite HTTP 200, and that AWS SDKs handle this condition according to their error-handling configuration. A status code alone is therefore insufficient.

The driver retains the returned response and requires CopyObjectResult before read-back. It then verifies the declared state independently. A response object is evidence about the request; it is not a replacement for those reads.

Do not promote an ETag, body digest, or successful baseline into proof that a subsequent REPLACE request preserved its fields. Each candidate has its own request, expected manifest, observed state, and source-preservation check. Acceptance belongs to that evidence chain, not to the general availability of the copy API.

Reproduce the partial replacement that drops metadata keys

S2_partial_replace isolates the mistake without also dropping a header. It sets MetadataDirective="REPLACE", supplies only {"owner":"release"}, and explicitly includes both ContentType and CacheControl. Its expected user-defined map contains just one entry.

That expectation follows the replacement contract: the request supplies the new metadata rather than merging individual entries into the old map. AWS's copying-objects guidance specifically warns that changing metadata requires explicitly supplying the other user-configurable metadata to retain.

The critical comparison is exact map equality. An assertion that reads only observed["Metadata"]["owner"] would validate the requested change while ignoring both required survivors. Likewise, testing that the expected owner entry is a subset of the observed map would not establish the complete approved key set.

For this authored example, S2's expected missing-key set relative to the approved candidate is {"schema", "dataset"}. There are no authorized deletions. The body, selected headers, and seed tag are still expected to match their declarations. That combination makes S2 a useful negative control: it isolates user-metadata loss rather than bundling several defects together.

Classify the candidate as REPAIR, even if its read-back exactly matches S2's experimental expectation. A laboratory case can pass because it reproduced a bad migration outcome. That is not a contradiction; the test expectation and the business acceptance condition answer different questions.

Do not respond by changing the preservation contract to contain only owner. Repair the request by restoring the approved entries, then generate a separately identifiable candidate. Preserve the bad candidate's request and evidence so the review can explain what failed and what changed in the repair.

If a live S2 result instead contains unexpected metadata, do not silently relabel it successful. The control did not behave as declared, or the evidence does not describe the intended experiment. Record the discrepancy and use HOLD until the cause is understood. A surprising outcome is evidence to investigate, not permission to weaken the oracle.

Supply the complete approved metadata replacement

S3_complete_replace is the owner-change candidate. It supplies all three approved entries: owner=release, schema=v1, and dataset=lab, along with ContentType=text/plain and CacheControl=max-age=60. The tag expectation remains {"stage":"seed"} because this case does not request a tag-policy change.

The repair is not “send a larger dictionary.” It is “send the reviewed replacement dictionary.” Those are different controls. A broad spread of arbitrary fields from an unreviewed source could retain an entry the application owner intended to remove, carry forward an unexpected value, or conceal disagreement about which fields belong in the contract.

Here, UPDATED is an explicit authored manifest. Review its key set and values before execution. Compare it with ORIGINAL: the key set is unchanged, and the only changed value is owner. Independently review HEADERS rather than assuming that correctness of UPDATED establishes header preservation.

Following live execution, S3 qualifies for ACCEPT_BOUNDED only when its observed manifest, retrieved body, and source-after evidence all agree with their declarations and the laboratory preconditions held. Before those reads exist, ACCEPT_BOUNDED is merely the row's expected decision. Its presence in a plan is not an approval.

Keep the scope in the acceptance record: this source, this destination, this approved map, these two headers, this tag set, and these body bytes. Do not let a successful narrow fixture become an undocumented authorization for a broader field set or storage workflow.

A source read is not a concurrent snapshot protocol

The function named snapshot makes separate sequential calls. Its name does not make HEAD, GET, and tag retrieval one atomic observation. The driver depends on exclusive ownership and a source that is not changing.

It checks the source after each completed destination reconciliation, but matching successive observations cannot rule out every intervening change. In particular, this fixture does not qualify changes that occur and are then reversed between reads.

Similarly, the destination absence check is a preflight, not an atomic destination-creation guard in this driver. Do not run it among concurrent writers. An unexpected source change or lost isolation is HOLD. Establish a separately reviewed concurrency protocol for that different problem; adding an untested condition here would not supply the missing evidence.

Prove that a correct Metadata map can still lose a header

S4_omitted_cache_control keeps the full updated user-defined map and explicitly provides ContentType=text/plain, but omits CacheControl from its REPLACE request. Its expected destination therefore lacks the approved cache header. This control applies the requirement to restate retained metadata to a system header rather than a user-defined entry.

In the manifest, Python None represents absence of CacheControl from the HEAD response; JSON renders that value as null. It does not mean that the request sent a literal null cache header. Nor is absence equivalent to an empty string or an explicitly approved alternative caching directive.

S4 should expose a validator that looks only at Metadata. The owner is updated, the two other user-defined entries remain, and the body is unchanged, yet the preservation contract is still violated. The candidate requires REPAIR because max-age=60 was required to survive.

Do not turn this into an unmeasured claim about a browser, CDN, or downstream cache. The fixture establishes only the header contract at the copied object. It does not send a consumer request, inspect cache storage, or measure response reuse.

S5_header_only_replace reverses the blind spot. It supplies both approved headers but sends an empty Metadata dictionary under REPLACE. Its expected user-defined map is {}. This is intentional removal of the authored entries, not an inability to read them.

S5 therefore catches a validator that checks headers and body but ignores user metadata. It also fails the requested owner change because no owner entry remains. Its decision is REPAIR, regardless of whether its header checks are green.

Keep both controls in the review. Removing S4 would leave header omission unchallenged; removing S5 would leave header-only acceptance unchallenged. Together with S2, they show three distinct ways an incomplete request or incomplete validator can satisfy part of the contract while violating the whole.

When documenting the defect, name the missing dimension and exact expected value. “Metadata problem” is less actionable than “CacheControl absent; required max-age=60,” especially when different owners review application fields and HTTP behavior.

Verify tags through their own directive and read path

S6_tags_are_separate explicitly uses MetadataDirective="COPY", sets TaggingDirective="REPLACE", and supplies Tagging="stage=review". The expected metadata map remains ORIGINAL, both named headers remain unchanged, and the expected tag map becomes {"stage":"review"}. It is a TAG_CONTROL, not an owner-change candidate.

The API gives tag copying and replacement their own directive. GetObjectTagging retrieves the associated tag set; the returned TagSet is the evidence used by the driver. A lookup inside Metadata cannot supply that tag-set evidence.

The driver turns the returned key-value entries into a map before comparison. For this one-tag fixture, the whole expected set is easy to review: exactly stage=review for S6 and exactly stage=seed for the other five cases. Testing only for the presence of stage would leave its value unverified.

Do not substitute a stage user-metadata entry for the actual tag or interpret a successful tag read as proof that schema survived. Those substitutions erase the separation the control was designed to exercise.

An unchanged body does not establish field preservation

GetObject supplies the body read. The fixture compares returned bytes against BODY, not against a source-derived digest alone.

The literal's SHA-256 is 523029e6be57243b2965ba0c5d860a31bd488b04f66f58236cf5d61d306088d4. The driver reads at most 23 bytes, its expected 22 bytes plus one, and closes the response stream in finally. A short body, a different byte, or an extra byte prevents equality. This is a bounded application read for a tiny authored object, not a large-object transfer strategy or a guarantee about transport-level buffering.

All six cases deliberately expect the same body. Their metadata, header, and tag expectations differ. The body hash is not defective when it remains identical across those cases; it answers a different question.

Record successful byte equality and the resulting digest alongside the descriptive-state evidence. Do not replace either side of that pairing with the other. The central lesson is not “checksums are insufficient for everything,” but “choose an independent check for each part of the contract.”

Reconcile the six destinations with the source after each copy

The table below is the expected matrix, not a report of AWS execution. ORIGINAL means {"owner":"qa","schema":"v1","dataset":"lab"}; UPDATED means {"owner":"release","schema":"v1","dataset":"lab"}. Every row expects exact body equality with BODY. S1 through S6 refer to the correspondingly named cases in the driver.

Case

Metadata

ContentType

CacheControl

Tags

Expected role or decision

S1

ORIGINAL

text/plain

max-age=60

{"stage":"seed"}

BASELINE

S2

{"owner":"release"}

text/plain

max-age=60

{"stage":"seed"}

REPAIR

S3

UPDATED

text/plain

max-age=60

{"stage":"seed"}

ACCEPT_BOUNDED, after live reconciliation

S4

UPDATED

text/plain

Absent (None)

{"stage":"seed"}

REPAIR

S5

{}

text/plain

max-age=60

{"stage":"seed"}

REPAIR

S6

ORIGINAL

text/plain

max-age=60

{"stage":"review"}

TAG_CONTROL

For each completed case, retain its full request, copy response, destination HEAD result, tag response, and successful body-comparison digest. Then inspect source_after: it must still describe the original metadata, headers, tags, and body. A valid destination without the source check is incomplete evidence for this protocol.

The row status PASS means the observed case matched its own declared expectation. PASS_6_AWS_CASES means all six experimental comparisons completed successfully. Neither status means that all six objects meet the owner-change contract. S2, S4, and S5 are expected to demonstrate defects and retain their REPAIR classification.

The program stores these classifications as expected_decision; it does not implement a separate production promotion gate. Reviewers must not convert that planning field directly into an observed approval. The offline report contains no observed manifest at all.

Count identities as well as successes: six unique case IDs, six distinct destination keys, and one retained source. An incomplete run must retain its partial status rather than being summarized as “copy tested.”

The comparison with per-key deletion reconciliation as a different workflow is about evidence discipline, not shared operations. Here, the question is whether each newly completed object matches its declared state. Nothing is deleted, and object absence is not the final success criterion.

Handle missing evidence and ambiguous copy outcomes

A missing response does not prove that no destination exists. The supplied live protocol deliberately leaves ambiguous writes unresolved rather than inventing an outcome. After a timeout or interrupted response, the operating decision is HOLD until an authorized reviewer establishes the state of the planned keys.

The distinction between created_keys and the plan is important. The driver appends a destination only after copy_object returns. If a request created an object but did not return normally, that destination might be absent from created_keys. The same limitation applies to the source upload. The local list is a progress ledger, not an authoritative bucket inventory.

Reconcile the recorded source_key and every planned destination_key, including cases not marked PASS. Preserve the unique prefix before any manual cleanup. Do not assume a later row was executed merely because its key appears in the plan, or assume an earlier attempted key is absent because it is missing from the success ledger.

The error record contains an exception type and message, while already attached responses remain in the report. It is not a complete capture of every failed response. In particular, a snapshot that fails before returning does not attach its partial HEAD, GET, or tag observations to the row. The driver also does not persist the full GET response or downloaded body.

These are evidence limitations, not successful negative checks. Retain available process output and authorized diagnostic records; arrange independent reconciliation when a required result is missing. Do not manufacture request IDs, fill an observed column from the expected matrix, or downgrade denied access to object absence.

The finally block attempts to write results.json, but a local write failure can prevent delivery of that report. Stop if the evidence file is unavailable or incomplete. No separate fault-injection campaign establishes reliable reporting under every filesystem or transport failure.

This is also why multipart cleanup uses a different unit of work. The present fixture concerns completed objects at planned keys, not multipart upload identities or residual parts. Do not import an abort procedure into metadata-copy reconciliation simply because both workflows use S3.

Recover a bad candidate without overwriting the retained source

A bad candidate need not become the new source of truth. This fixture keeps its original source at a different key and gives every comparison a fresh destination. That layout preserves the intended reference point while the requests and their consequences remain inspectable.

For S2, the repair is to restore the missing approved metadata entries in the replacement request. For S4, it is to supply the approved cache header. For S5, it is to restore the entire approved user-defined map, including the changed owner. None of those repairs requires weakening the body or tag checks.

Use a deliberate recovery sequence:

1.   Retain the candidate and its available request/evidence record; classify the known contract defect as REPAIR, with unresolved observations still on HOLD.

2.   Re-establish the retained source's approved state and exclusive control. Review the complete corrected manifest, not only the last missing field.

3.   Authorize a new candidate key, apply the corrected request, and reconcile the destination and retained source before making a bounded acceptance decision.

That sequence describes a separately controlled recovery attempt. The supplied driver is not a resume command: rerunning it generates a new prefix and creates a new synthetic source. It does not automatically recover a candidate from a previous run. Any adapted driver needs its own reviewed source digest and actual execution record.

Use version recovery rather than metadata copying only when the problem and bucket contract actually concern versions. This experiment neither restores versions nor removes markers. Its recovery reference is the separately retained, approved source.

Make the cleanup and rollback boundary explicit

The live driver intentionally deletes nothing. Keep created objects available until the authorized review resolves the experiment, including any write whose outcome was ambiguous.

Manual cleanup must reconcile the unique planned prefix under the laboratory's approved policy. Do not base cleanup solely on created_keys, broaden it to unrelated prefixes, or enable bucket changes to simplify disposal.

Removing laboratory objects is not production rollback. Likewise, qualifying a repaired candidate does not prove that downstream consumers have switched to it or reverted from another object. Consumer cutover, production recovery, and their evidence require separate plans.

Assign ownership of the metadata migration gate

The migration gate needs owners for the contract, the execution boundary, and the evidence. Assigning every concern to “the script” leaves business decisions implicit and makes a green process status look more authoritative than it is.

The application owner defines the allowed metadata change and every required survivor. That owner should approve exact keys and values, including whether absence is ever allowed. In this example, removal is not approved: schema, dataset, and the selected cache header must survive.

The cloud owner approves the bucket, expected account, region, identity scope, and access needed by the laboratory. The migration owner establishes exclusive use of the prefix and keeps the source quiescent. QA checks the six-case comparison, the baseline, destination evidence, and source-after records. One person may hold several responsibilities, but none should disappear from the review.

For an actual owner-change candidate, the operating decision has three useful branches. ACCEPT_BOUNDED requires complete matching live evidence within the declared scope. REPAIR identifies a demonstrated request or contract defect that needs a corrected candidate. HOLD covers missing access, ambiguous writes, concurrent changes, missing evidence, or an unexplained result. A known defect can coexist with incomplete evidence; repair authorization does not resolve the latter.

Place those decisions beside the experiment's BASELINE, TAG_CONTROL, and PASS labels rather than replacing them. A reviewer must be able to distinguish “the negative control behaved as predicted” from “the candidate is approved for the requested metadata change.”

Revalidate when the preserved field set, required headers, tagging policy, SDK environment, or storage requirements change. The prior result does not automatically cover a new contract. Changes to the driver should produce a new digest; changes to the environment should be recorded with the new evidence.

For downstream cloud data contracts, make the approved fields visible to the consuming team. This fixture does not establish how any external pipeline interprets them. Ask that team to name its own acceptance conditions rather than assuming that unchanged bytes imply unchanged application behavior.

The final approval record should identify what was checked, what was excluded, which observations support the decision, and who owns unresolved work. That makes the boundary of acceptance reviewable instead of leaving it hidden behind a single success flag.

Build practical cloud operating skills through structured learning

This investigation connects several practical habits: translating a change request into an explicit contract, separating API controls, using an isolated environment, reading back results, and retaining evidence when an outcome is uncertain. Those habits are useful learning goals alongside implementation skills.

For structured study of the surrounding foundations, explore Refonte Learning's Cloud Development programme. Its published outline includes cloud architecture, application deployment, infrastructure as code, CI/CD, security, monitoring, and a capstone project. The stated format is three months at 12–15 hours per week. These are broader foundations, not confirmation that this exact CopyObject laboratory is taught.

Bring the same discipline back to the migration review: declare the permitted change, retain the approved source, build the complete replacement request, and validate every named field through the appropriate read path. An unchanged body is necessary for this contract, but it is never the whole acceptance decision.