Four cloud engineers reviewing an Amazon S3 delete marker and retained object versions on an office display.

S3 If-None-Match: Key Reuse When a Delete Marker Is Current

Sat, Oct 24, 2026

A conditional S3 write can be accepted even when the key has a retained past. That is the exact boundary this lab is designed to expose. In a versioning-enabled general purpose bucket, AWS documents that If-None-Match: * is evaluated against the current version: when the current version is a delete marker, the conditional write can succeed even though an older data version remains in the version history. AWS introduced S3 conditional writes on August 20, 2024, so this is not a new 2026 capability; the continuing engineering problem is whether an application's contract actually means “no current data” or “this name must never have been used.”

Those are different requirements. A current-absence rule may permit reuse after a delete marker becomes current. A lifetime-uniqueness rule does not. The experiment below keeps that distinction narrow: one owned bucket with versioning already Enabled, one exclusive fresh prefix, sequential requests, tiny known payloads, SSE-S3, one target key, and one untouched sentinel. It excludes restoration, marker removal, copy operations, multipart uploads, retention design, KMS, race reproduction, and benchmarking.

Evidence status as of October 24, 2026: DOCUMENTED BEHAVIOR is supported by the AWS references cited throughout. EXPECTED LAB RESULT is the matrix and procedure below. OBSERVED RESULT: none is claimed because no authorized AWS service run was supplied or performed during this research. The provided fixture was locally syntax-checked with Python 3.13.5; the local environment had boto3 1.43.18 and botocore 1.43.18, and that installed botocore model contains PutObject.IfNoneMatch. None of those local checks establishes AWS behavior. A service decision remains pending real evidence and application-owner approval.

Decide what an unused key means

“Unused” is too vague for an admission rule. Before touching the bucket, define the application contract in terms that can be tested.

Current absence means the target key has no current data object that blocks If-None-Match: *. In a versioning-enabled bucket, AWS explicitly says the conditional write succeeds when there is no current object version or when the current version is a delete marker; otherwise it fails with 412 Precondition Failed. That is the service contract this fixture qualifies.

Retained historical use means evidence exists that the same key named data in the past. S3 Versioning keeps multiple variants independently, and a simple delete in a versioning-enabled bucket inserts a delete marker rather than permanently removing the earlier data version. A retained V1 therefore proves prior use within this controlled run even when a current GET behaves as though the object is deleted.

Lifetime uniqueness is stronger: once the application has ever allocated a name, that name must never be admitted again. If-None-Match: * does not express that history-wide requirement because AWS documents its versioned-bucket evaluation against the current state.

The application owner must choose which rule is intended. The cloud operator owns the bounded resource conditions that make the evidence interpretable, and the reviewer checks that the report supports the requested decision. Before the lab begins, the policy outcomes should already be clear: a current-absence contract may eventually reach ACCEPT_CURRENT_ABSENCE after real evidence and owner approval; a never-reuse contract stays HOLD even if C4 succeeds exactly as AWS documents.

The fresh prefix is only a controlled-run baseline. An empty version listing under that prefix lets this experiment start from a known observable state, but it cannot prove that the same logical name was never used somewhere else, that historical records were never erased, or that some previous system did not use an equivalent business identifier. “Fresh for this test” is not “unused for all time.”

Freeze the storage and caller boundary

Record the bucket, prefix, and actors

Use a pre-existing, owned general purpose S3 bucket whose versioning status is already Enabled. Versioning-enabled and versioning-suspended are distinct bucket states, and this procedure intentionally rejects suspension because null-version behavior would change the evidence model. Directory buckets are also outside scope because S3 Versioning is not supported there.

Choose an exclusive prefix matching the fixture's conditional-admission-.../ pattern. No unrelated writer, lifecycle action, replication process, deployment job, or test runner should mutate that prefix during the run. The reason is not that S3 listings are weak; it is that this procedure makes multiple sequential API calls and interprets them as one controlled narrative. Another actor can legitimately change the state between those calls and invalidate that interpretation.

Record the bucket name, AWS Region, expected owner account ID, approved test authority, target prefix, and operator identity in the surrounding run record. ExpectedBucketOwner is useful as an account guard: AWS documents a 403 Forbidden when the supplied account ID does not match the bucket owner. It does not grant permission or prove organizational authorization.

The fixture uses SSE-S3 by passing ServerSideEncryption="AES256", which is an accepted put_object parameter value in the Boto3 S3 client. KMS, Object Lock, retention policy design, and alternate bucket types are deliberately excluded. Likewise, preserving metadata during an S3 copy is a different mutation contract and should not be mixed into this write-admission experiment.

Make permissions and attempts observable

The caller needs enough authority to both perform the mutations and interpret the evidence: s3:GetBucketVersioning, s3:ListBucketVersions, s3:ListBucket, s3:PutObject, s3:DeleteObject, s3:GetObject, and s3:GetObjectVersion within the approved scope. AWS documents s3:ListBucketVersions for ListObjectVersions, s3:PutObject for the write, s3:DeleteObject for the simple delete, and distinct current-versus-version-specific GET permissions.

ListBucket matters to the absence check because AWS documents that a missing current object can produce 404 Not Found when the caller has s3:ListBucket, but 403 Access Denied when it does not. A 403 therefore cannot be treated as evidence of absence. The fixture does not delete a specific version, so it does not need s3:DeleteObjectVersion; AWS requires that permission when a request permanently deletes a named version.

Record the actual Python, boto3, and botocore versions from the execution environment. The rolling Boto3 documentation page currently displays its own documentation build version, but that is not evidence of what the operator has installed. The fixture records the local packages into the JSON report and also checks that the installed service model exposes IfNoneMatch, whose exact Boto3 parameter name is documented as IfNoneMatch.

Finally, the client uses retries={"mode":"standard","total_max_attempts":1}. Boto3 documents that total_max_attempts in a Config object includes the initial request, so a value of one exposes a single attempted request at each mutation step rather than silently converting the evidence protocol into a retry loop. Record ResponseMetadata.RetryAttempts from responses as part of review evidence when present. Do not add an unconditional PUT fallback.

Build independent payloads and the evidence recorder

Keep the oracle outside the response being tested

The evidence is stronger when expected content is authored independently of the service response. This fixture uses three fixed byte strings: A=b"claim=A\n", B=b"claim=B\n", and the separate sentinel b"sentinel=keep\n". The target key and sentinel key are fixed beneath the run prefix. The report records SHA-256 values for the source and payloads, but the verifier still checks exact byte equality and expected length before treating a digest as supporting evidence.

That ordering matters. An ETag is useful metadata, but it is not a universal substitute for an explicit body oracle. The S3 PutObject reference only describes MD5 comparison for cases where the ETag is in fact the object's MD5 digest; it does not define every ETag as a universal content hash.

The recorder also captures opaque VersionId values, IsLatest, entry kind, request response or error structures, timestamps, and snapshots. AWS documents version IDs as identifiers returned for versioned objects and exposes both data versions and delete markers through ListObjectVersions; no application logic here infers chronology from the characters inside a version ID.

Save the complete baseline below as s3_admission_lab.py. Preserve its fail-closed behavior when changing formatting or surrounding tooling. No AWS execution output is supplied with this article.

import argparse
import hashlib
import importlib.metadata as md
import json
import platform
import re
from datetime import datetime, timezone
from pathlib import Path

import boto3
from botocore.config import Config
from botocore.exceptions import ClientError

A, B, CONTROL = b"claim=A\n", b"claim=B\n", b"sentinel=keep\n"
def require(condition, message):
    if not condition:
        raise RuntimeError(message)
def utc():
    return datetime.now(timezone.utc).isoformat()
def digest(value):
    return hashlib.sha256(value).hexdigest()

ap = argparse.ArgumentParser()
ap.add_argument("--bucket", required=True)
ap.add_argument("--owner", required=True)
ap.add_argument("--prefix", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--authorized-test", action="store_true")
args = ap.parse_args()
require(args.authorized_test, "Explicit controlled-test authorization required")
require(re.fullmatch(r"[0-9]{12}", args.owner), "Use the expected owner account ID")
require(re.fullmatch(r"conditional-admission-[a-z0-9-]+/", args.prefix),
        "Use a unique conditional-admission-.../ prefix")
out = Path(args.out)
require(not out.exists(), "Preserve the existing report; choose a new output path")
require(out.parent.is_dir(), "Output directory must already exist")
target, sentinel = args.prefix + "claim-0042.txt", args.prefix + "sentinel.txt"
session = boto3.Session()
s3 = session.client("s3", config=Config(
    signature_version="s3v4", retries={"mode": "standard", "total_max_attempts": 1},
    connect_timeout=10, read_timeout=30))
require("IfNoneMatch" in s3.meta.service_model.operation_model(
    "PutObject").input_shape.members, "Installed SDK model lacks IfNoneMatch")
common = {"Bucket": args.bucket, "ExpectedBucketOwner": args.owner}
report = {
    "status": "RUNNING_NOT_ACCEPTED", "started_utc": utc(),
    "python": platform.python_version(), "boto3": md.version("boto3"),
    "botocore": md.version("botocore"), "region": s3.meta.region_name,
    "bucket": args.bucket, "expected_owner": args.owner, "prefix": args.prefix,
    "source_sha256": digest(Path(__file__).read_bytes()),
    "payload_sha256": {"A": digest(A), "B": digest(B), "sentinel": digest(CONTROL)},
    "events": [], "snapshots": []}

def save():
    temp = out.with_name(out.name + ".tmp")
    temp.write_text(json.dumps(report, indent=2, default=str) + "\n", encoding="utf-8")
    temp.replace(out)

def call(label, operation, kwargs):
    event = {"label": label, "operation": operation, "utc": utc()}
    try:
        response = getattr(s3, operation)(common, **kwargs)
        event["response"] = {k: v for k, v in response.items() if k != "Body"}
        report["events"].append(event)
        save()
        return response, None
    except ClientError as exc:
        event["error_response"] = exc.response
        report["events"].append(event)
        save()
        return None, exc.response
    except Exception as exc:
        event["client_failure"] = {"type": type(exc).__name__, "message": str(exc)}
        report["events"].append(event)
        save()
        raise RuntimeError("HOLD: request outcome requires reconciliation") from exc
def success(label, operation, kwargs):
    response, error = call(label, operation, kwargs)
    require(error is None, "HOLD: unexpected service error at " + label)
    require(200 <= response["ResponseMetadata"]["HTTPStatusCode"] < 300,
            "HOLD: unexpected status at " + label)
    return response
def version(response):
    value = response.get("VersionId")
    require(value and value != "null", "HOLD: missing/non-versioned identity")
    return value
def put(label, key, body):
    response = success(label, "put_object", Key=key, Body=body,
                       IfNoneMatch="*", ServerSideEncryption="AES256")
    require(response["ResponseMetadata"]["HTTPStatusCode"] == 200,
            "HOLD: unexpected PutObject status")
    return version(response)
def blocked(label, body):
    response, error = call(label, "put_object", Key=target, Body=body,
                           IfNoneMatch="*", ServerSideEncryption="AES256")
    require(error is not None
            and error["ResponseMetadata"]["HTTPStatusCode"] == 412
            and error["Error"]["Code"] == "PreconditionFailed",
            "HOLD: expected 412; inspect any 409, success, or unknown outcome")
def read_exact(label, key, expected, expected_version, explicit=False):
    params = {"Key": key}
    if explicit:
        params["VersionId"] = expected_version
    response = success(label, "get_object", **params)
    stream = response["Body"]
    try:
        actual = stream.read(len(expected) + 1)
    finally:
        stream.close()
    require(version(response) == expected_version, "HOLD: wrong read version")
    require(response["ContentLength"] == len(expected) and actual == expected,
            "HOLD: body mismatch")
    report["events"].append({"label": label + "-body", "version": expected_version,
                             "bytes": len(actual), "sha256": digest(actual)})
    save()
def inventory(label):
    rows, cursor, seen = [], {}, set()
    while True:
        response = success(label + "-page", "list_object_versions",
                           Prefix=args.prefix, MaxKeys=1, **cursor)
        for field, kind in (("Versions", "data"), ("DeleteMarkers", "marker")):
            for row in response.get(field, []):
                require(row["Key"] in (target, sentinel), "HOLD: unexpected key")
                require(row.get("VersionId") not in (None, "", "null"),
                        "HOLD: missing version identity")
                rows.append((kind, row["Key"], row["VersionId"], row["IsLatest"]))
        if response.get("IsTruncated") is False:
            break
        require(response.get("IsTruncated") is True, "HOLD: unknown pagination state")
        token = (response.get("NextKeyMarker"), response.get("NextVersionIdMarker"))
        require(all(part is not None for part in token) and token not in seen,
                "HOLD: incomplete/repeated pagination cursor")
        seen.add(token)
        cursor = {"KeyMarker": token[0], "VersionIdMarker": token[1]}
    report["snapshots"].append({"label": label, "utc": utc(), "rows": rows})
    save()
    return rows
def state(label, expected):
    actual = inventory(label)
    require(len(actual) == len(expected) and set(actual) == set(expected),
            "HOLD: exact version ledger mismatch at " + label)

save()
try:
    settings = success("preflight-versioning", "get_bucket_versioning")
    require(settings.get("Status") == "Enabled", "HOLD: versioning must be Enabled")
    state("preflight-empty", [])
    s1 = put("create-sentinel", sentinel, CONTROL)
    v1 = put("C1-initial-A", target, A)
    base = [("data", sentinel, s1, True), ("data", target, v1, True)]
    state("C1-ledger", base)
    read_exact("C1-current-A", target, A, v1)
    read_exact("C1-sentinel", sentinel, CONTROL, s1)
    blocked("C2-B-against-current-A", B)
    state("C2-unchanged", base)
    read_exact("C2-current-A", target, A, v1)
    deleted = success("C3-create-one-marker", "delete_object", Key=target)
    require(deleted.get("DeleteMarker") is True, "HOLD: delete did not identify marker")
    d1 = version(deleted)
    require(d1 != v1, "HOLD: marker identity collision")
    marked = [("data", sentinel, s1, True), ("data", target, v1, False),
              ("marker", target, d1, True)]
    state("C3-marker-current", marked)
    response, error = call("C3-current-read", "get_object", Key=target)
    if response is not None:
        response["Body"].close()
    require(error is not None and error["ResponseMetadata"]["HTTPStatusCode"] == 404
            and error["ResponseMetadata"].get("HTTPHeaders", {}).get(
                "x-amz-delete-marker") == "true", "HOLD: expected marker-specific 404")
    read_exact("C3-retained-A", target, A, v1, explicit=True)
    v2 = put("C4-B-after-marker", target, B)
    require(v2 not in (v1, d1), "HOLD: new data identity missing")
    final = [("data", sentinel, s1, True), ("data", target, v1, False),
             ("marker", target, d1, False), ("data", target, v2, True)]
    state("C4-new-current-B", final)
    read_exact("C4-current-B", target, B, v2)
    read_exact("C4-retained-A", target, A, v1, explicit=True)
    blocked("C5-A-against-current-B", A)
    state("C5-unchanged", final)
    read_exact("C6-current-B", target, B, v2)
    read_exact("C6-retained-A", target, A, v1, explicit=True)
    read_exact("C6-sentinel", sentinel, CONTROL, s1)
    state("C6-final-ledger", final)
    report["status"] = "LAB_EXPECTATIONS_MATCHED_OWNER_REVIEW_REQUIRED"
    report["policy"] = "Current-absence requires owner approval; never-reuse remains HOLD"
except Exception as exc:
    report["status"] = "HOLD"
    report["failure"] = {"type": type(exc).__name__, "message": str(exc)}
    raise
finally:
    report["finished_utc"] = utc()
    save()

Run it only after the resource and authority preconditions are satisfied, in an isolated local environment with approved boto3 and botocore installed. Replace every example argument with the authorized bucket, expected account ID, unused prefix, and a new report path. Use existing approved AWS credential configuration; credentials do not belong in the script or JSON report.

python s3_admission_lab.py \
  --bucket YOUR-OWNED-TEST-BUCKET \
  --owner 123456789012 \
  --prefix conditional-admission-your-unique-run-id/ \
  --out s3-admission-run.json \
  --authorized-test

The authorization flag records an operator acknowledgement; it cannot prove ownership, isolation, or absence of background actors. Every created data version and the delete marker remain after a successful run. A failed run may also have mutated S3. Preserve its report, stop, and diagnose with read-only inspection before authorizing a separate run under a new prefix.

Establish the first conditional version

C1 starts only after two preflight conditions hold: bucket versioning is reported as Enabled, and a complete ListObjectVersions walk for the exclusive prefix returns no rows. ListObjectVersions is the right inventory primitive because AWS defines it as returning metadata about object versions and exposes both Version and DeleteMarker entries.

The fixture then creates the sentinel and writes payload A to the target with IfNoneMatch="*". In a real service run, the expected successful PutObject status is 200, and a versioning-enabled bucket returns a VersionId; both identifiers are captured as opaque values, symbolically called S1 and V1 in the narrative.

The first target ledger is therefore exactly two entries: sentinel S1 is current, and target V1 is current. An ordinary GetObject of the target must return V1 and exact bytes b"claim=A\n"; the sentinel must likewise return S1 and its original bytes. The symbolic names are review labels, not substituted cloud evidence. A real report must contain the actual returned version IDs and response metadata.

This C1 state is a controlled baseline, not a lifetime provenance claim. The empty prefix must be observed at the start of the experiment. It does not establish that the business identifier behind claim-0042.txt has never existed outside that bounded history.

Prove rejection while data is current

C2 asks the first discriminating question: what happens when current data already occupies the key? The fixture attempts payload B with the same IfNoneMatch="*" condition while V1/A is current.

DOCUMENTED BEHAVIOR: AWS says If-None-Match fails with 412 Precondition Failed when an existing current object blocks the condition. The PutObject reference also identifies 409 ConditionalRequestConflict as a different conflict outcome when another operation interferes during the upload.

EXPECTED LAB RESULT: C2 receives 412 with error code PreconditionFailed. That response is necessary evidence, but the procedure does not stop there. It re-lists the complete prefix and requires the ledger to remain exactly S1 current plus V1 current, then reads V1 again and compares the body to A.

Why reconcile after an expected rejection? Because the application decision concerns state, not merely an exception branch. A client that catches 412 and records “duplicate rejected” without verifying the bounded state has weaker evidence than one that confirms no unexpected version appeared and the known current content is still A. This is especially important in a reusable validation harness, where transport, SDK, or environmental surprises should not be silently collapsed into the same category.

There is no unconditional fallback. If the conditional request returns 409, 403, an unexpected success, or a client-side failure with uncertain outcome, the case leaves the expected path and moves to HOLD for reconciliation.

Create one marker without deleting a version

C3 deliberately uses one simple DeleteObject request: it supplies the key and no VersionId. In a versioning-enabled bucket, AWS documents that this inserts a delete marker that becomes the current version; permanently deleting a specific object version requires supplying that version ID. The API reference documents a successful simple delete response with HTTP 204, and the response can include the delete-marker indicator and version ID.

The returned marker identity becomes D1. The expected ledger is now sentinel S1 current, V1 retained but noncurrent, and D1 current for the target. The fixture never calls DeleteObject with VersionId, so V1 is not intentionally removed.

The next ordinary target GET is expected to fail in a marker-specific way. AWS documents that when the current version is a delete marker, a GET without versionId returns 404 Not Found and includes x-amz-delete-marker: true. That is evidence of current absence as exposed by the object read path, not evidence that the key lacks history.

The distinction is then proved directly: the fixture performs a version-specific GET for V1 and requires the exact A bytes. GetObject defaults to the current version, while supplying versionId retrieves that named version and requires version-specific read permission. Thus C3 is the controlled counterexample state: an ordinary current read says “deleted,” while retained historical data is still addressable by V1.

This article does not remove D1 or restore V1. That different operational objective is covered by recovering a specific version behind delete markers, where marker removal and restoration acceptance are the point of the exercise. Here the marker stays in history and becomes the precondition for the next admission test.

Read history behind current absence

Carry both continuation markers to the terminal page

A reliable S3 key reuse validation needs the full version ledger, not a convenient first page. ListObjectVersions returns data versions and delete markers as separate response collections, includes IsLatest, and reports truncation plus NextKeyMarker and NextVersionIdMarker for continuation. AWS tells callers to use both continuation values when results are truncated.

The fixture forces pagination with MaxKeys=1. That is intentionally inefficient for such a tiny dataset because it exercises the cursor logic every time. On each page it accepts only the two exact authored keys, requires a non-null version identity, records (kind, key, VersionId, IsLatest), and continues until IsTruncated is explicitly False. If truncation is true but either continuation value is missing, or the cursor repeats, the run enters HOLD.

The comparison is set-based rather than order-based. Versions and DeleteMarkers are distinct arrays in the API model, and nothing in this lab needs to infer cross-array chronology from their presentation order. Likewise, the code treats VersionId as an opaque identity. The narrative names V1, D1, and V2 express the fixture's known operation sequence; their actual AWS values are never parsed for time or ordering.

The inventory also has a strict scope check. Any key other than the target or sentinel beneath the prefix is unexpected evidence and stops acceptance. That catches a broken “exclusive prefix” assumption instead of letting the verifier quietly ignore an uncontrolled writer.

One limitation must remain explicit: walking a paginated version listing requires multiple service calls. This procedure does not claim that those calls form one atomic historical snapshot. Its interpretation depends on the operational precondition that no external writer, lifecycle process, replication activity, or second copy of the test is changing the prefix while the ledger is collected. The fixture's sequential calls make that precondition practical to review; they do not turn the listing API into a transaction.

An ordinary object listing is not a substitute. AWS notes that delete markers are discoverable through ListObjectVersions, while ordinary object listings omit keys whose current version is a delete marker. For this question, “not visible in a normal list” answers the current namespace view; it does not inventory retained versions.

Repeat the condition after the marker

C4 repeats exactly the admission condition that failed in C2: PutObject with IfNoneMatch="*", now writing B. The critical difference is the prestate. V1 still exists, but D1 is the current version.

DOCUMENTED BEHAVIOR: AWS's conditional-write guidance is explicit for versioning-enabled buckets: if no current object version exists or if the current object version is a delete marker, the If-None-Match write succeeds; otherwise it fails with 412. The header therefore expresses a condition on current occupancy, not a scan across all retained historical versions.

EXPECTED LAB RESULT: C4 returns 200 and a new non-null version identity V2. The fixture then requires the exact final target ledger to contain V1 as noncurrent data, D1 as a noncurrent marker, and V2 as current data, while S1 remains current for the sentinel. A current GET must return V2 and exact B; a version-specific GET of V1 must still return exact A. Successful PutObject responses in a versioning-enabled bucket can return VersionId, which is why the new admission has a separately reviewable identity.

That is the narrow answer to the article's title question: yes, according to current AWS documentation, PutObject with IfNoneMatch="*" can reuse a key when a delete marker is current even though an older data version remains.

It is not a security defect, a bypass, or a broken conditional write. It is the documented service contract. The policy question sits one layer above it: does the application define “unused” as current absence, or does it require a durable prohibition on ever reusing that name? C4 can satisfy the first contract and simultaneously violate the second.

The retained history is what makes the distinction reviewable. Without V1, a successful C4 would only show that the write was accepted in a state that looked absent. With V1 still readable, the same controlled run demonstrates that prior use and current write admission can coexist.

Reconcile the complete comparison matrix

Check versions, bytes, and the untouched sentinel

The service matrix below is EXPECTED, not measured. No observed AWS column is added because this research did not execute the fixture against an authorized bucket.

Case

Prestate and action

EXPECTED service/state evidence

Decision meaning

C1

Empty controlled prefix; conditional A

200, V1 current, exact A

Establish controlled baseline

C2

V1 current; conditional B

412; V1 and ledger unchanged

Expected current-data rejection

C3

Simple delete; inspect current and V1

D1 current; 404 + marker header; V1=A

Absence and history coexist

C4

D1 current; conditional B

200; V2=B current; V1/D1 retained

Owner must evaluate reuse

C5

V2 current; conditional A

412; final ledger unchanged

Condition still rejects current data

C6

Final reads and complete inventory

V1/D1/V2 exact; sentinel unchanged

Evidence complete within lab scope

C5 closes the semantic loop. Once V2/B is current, the fixture attempts A with the same conditional header and again expects 412 PreconditionFailed, followed by an unchanged final ledger. This shows that the condition did not become permissive because the key once had a marker; it is permissive specifically while the current version is the marker state described by AWS.

C6 then rechecks the three dimensions that matter most. First, identity: the target inventory must contain exactly V1 data, D1 marker, and V2 data with only V2 marked latest. Second, content: the current target is exact B, and version-specific V1 is exact A. Third, control integrity: the sentinel's S1 identity and b"sentinel=keep\n" body are unchanged.

The sentinel is not decorative. It makes the evidence sensitive to mistakes in prefix handling, accidental broad mutations, or a verifier that confuses target state with global run state. A target-only green result is weaker if the surrounding fixture has been altered unexpectedly.

ETags do not replace the body checks. The fixture reads one byte beyond the expected payload length, compares exact bytes, checks ContentLength, records SHA-256, and confirms the returned VersionId. This combination avoids turning a service-provided identifier or checksum-like field into the sole oracle for application content. AWS's own PutObject reference qualifies when an ETag can be compared as an MD5 digest rather than defining that relationship universally.

For review, keep response evidence and state evidence separate. A 412 is response-side evidence about the attempted write. An unchanged version ledger and exact body read are state-side evidence about what remains. A 200 is response-side evidence that PutObject succeeded; V2's recorded ID, current B bytes, retained V1, retained D1, and unchanged sentinel explain what that success means in this controlled history.

Stop on ambiguous or incomplete evidence

A disciplined admission test has more stop states than pass states. The fixture intentionally refuses to normalize every error into “duplicate” or every exception into “try again.”

412 PreconditionFailed is the expected duplicate-style outcome only in C2 and C5, where current data is known to be present. AWS documents that If-None-Match uses 412 when the existing current state fails the condition. A 409 ConditionalRequestConflict is different: the PutObject reference associates it with a conflicting operation during the upload, and AWS's guidance discusses it in concurrent-request scenarios. This fixture is sequential and controlled, so a 409 is unexpected evidence, not proof that current data existed.

A 403 is also not “absent.” For current reads of a missing object, AWS documents that callers without s3:ListBucket can receive 403 Access Denied instead of 404 Not Found. A denied ListObjectVersions likewise means the verifier lacks the historical visibility required for this decision. The correct disposition is HOLD, not inference.

A client timeout or connection failure is more serious than an ordinary service rejection because the write outcome may be unknown from the caller's perspective. The fixture records the client failure and raises HOLD: request outcome requires reconciliation. It does not claim that no write occurred, and it does not blindly repeat the mutation against the same prefix.

Missing VersionId, a null version identity, incomplete pagination, a repeated cursor, an unexpected key, the wrong body, or an unexpected successful PUT also causes HOLD. In the last case, the call() wrapper saves the successful response before later assertions stop the run, preserving evidence that a mutation may already have occurred.

AWS documentation may recommend retrying PutObject after a 409, while Boto3 provides general retry machinery. That generic service advice does not authorize a blind retry in this one-attempt evidence protocol. Here the purpose is to expose one attempted mutation per step and reconcile any deviation before deciding what to do next. A separate fresh-prefix RERUN can be authorized after diagnosis; that is not the same as replaying an ambiguous request.

The broader principle resembles reconciling per-key S3 deletion outcomes: an API envelope and the state evidence it implies are not interchangeable. The operations differ, but the review discipline is the same: preserve what the service actually said, then inspect the state required by the application's contract.

Repair the contract before changing the implementation

Recognize what a history scan cannot enforce

The most important outcome may be a policy correction rather than a code change.

If the application owner intended current absence, then names such as never_used, comments such as “prevents any key reuse,” or acceptance rules that interpret IfNoneMatch="*" as lifetime uniqueness are mislabeled. That is a REPAIR problem. Fix the contract language and review logic so they say what the S3 condition actually tests, then obtain explicit owner approval that reuse after a current delete marker is permissible. Only actual service evidence plus that owner decision can move the case to ACCEPT_CURRENT_ABSENCE.

If the requirement is lifetime uniqueness, keep HOLD. Do not “fix” the implementation by inserting ListObjectVersions immediately before PutObject and declaring the requirement solved. A read followed by a write is two operations, so another writer can change state between them unless a separate concurrency mechanism owns admission. Even in a quiescent lab, version history can only testify about history that is still retained; it cannot prove an erased past.

That means a lifetime-prohibition design needs a separate durable reservation or tombstone authority whose semantics explicitly cover concurrency, deletion, lifecycle, recovery, and administrative mutation. This article does not propose or qualify such an implementation. The design would need its own failure model and evidence before production use.

Likewise, preserving an old S3 version is not equivalent to reserving the key forever. S3 Versioning is designed to retain variants and allows new versions to be created; the current conditional-write rule still evaluates the current state as documented. A retained V1 is excellent evidence that the name was used in this lab, but it is not an enforcement primitive for never-reuse.

Object Lock is also outside this admission contract. Version-specific Object Lock release decisions concern retention and protection of identified versions, not whether If-None-Match: * should admit a new current version under the policy tested here. Do not infer from protection of an old version that a separate new-version admission is prohibited.

The design review should therefore ask one blunt question: what authority defines name uniqueness? If the answer is “the current S3 object state,” the current-absence contract can be made precise. If the answer is “a permanent business reservation,” S3's current-version conditional alone is the wrong authority.

Preserve the evidence and separate recovery work

Evidence should survive longer than the terminal output. Retain the JSON report, the exact script or its source hash, actual Python/boto3/botocore versions, bucket and expected-owner identifiers, approved prefix, authored payload hashes, service response structures, request IDs present in those structures, every version snapshot, and the final policy disposition. Boto3 exposes retry-attempt information through response metadata, so that should remain available for review rather than being discarded by a higher-level wrapper.

Do not erase the counterexample as automatic cleanup. The useful final history is precisely V1 retained, D1 retained, and V2 current. Deleting V2 or D1 would be a new version-specific mutation with a different purpose and its own authority requirements. AWS distinguishes a simple delete from permanent deletion of a specified version; rollback therefore deserves separate state review rather than an unexamined finally block.

Failed runs receive the same discipline. Because a report can fail after some successful mutations, keep the report, stop, and perform read-only diagnosis. A newly authorized experiment should use a fresh prefix. Replaying the same sequence against the same partially mutated history changes the preconditions and makes comparisons harder to review.

Multipart-upload cleanup is another separate lifecycle. Verifying multipart-upload cleanup tracks upload IDs and part state, not the current-versus-historical object version contract at issue here. Keeping those procedures separate prevents “cleanup” from becoming an umbrella term that erases the identity of the resource being reconciled.

Assign the final operational decision

The decision belongs to the application contract, not to the fact that AWS returned a technically successful response.

Decision

Use it when

Required owner/reviewer action

ACCEPT_CURRENT_ABSENCE

A real authorized run matches the expected C1–C6 ledger and exact-content checks, and the application owner explicitly permits reuse when a delete marker is current

Owner approves the current-absence contract; reviewer retains the evidence

REPAIR

Names, comments, tests, or fallback logic claim lifetime uniqueness while implementing only current absence, or an unsafe unconditional fallback exists

Correct the contract or implementation before approval

RERUN

The controlled test is incomplete for a diagnosable reason and the prior outcome has been reconciled

Authorize a new fresh-prefix run; do not replay an ambiguous mutation on the old prefix

HOLD

Lifetime uniqueness is required; a write outcome is unknown; visibility is denied; state is unexpected; pagination is incomplete; or another writer can affect the prefix

Stop mutation and resolve authority, evidence, or design gaps

Roles should be explicit. The application owner decides whether current absence is sufficient or whether names are never reusable. The cloud operator establishes the owned bucket, versioning state, isolation, permission boundary, and authorized run. The reviewer checks that expected responses, exact bytes, complete pagination, version identities, and sentinel state support the requested decision.

The narrow validated scope, once a real run exists, is only this: sequential PutObject calls with IfNoneMatch="*" around one simple delete in an owned, versioning-enabled general purpose bucket under an exclusive prefix, with SSE-S3 and complete version-ledger reconciliation. AWS's documentation already establishes the expected semantic: a current delete marker allows the conditional write even when an older version remains. The lab's purpose is to turn that documented rule into application-specific evidence.

As of this research, however, there is no OBSERVED AWS RESULT. The fixture's local syntax success and installed SDK-model check do not move the decision to ACCEPT_CURRENT_ABSENCE. The service matrix remains EXPECTED until an authorized run produces reviewable evidence, and owner approval remains a separate gate even after the service behavior matches expectation.

Develop the cloud judgment behind the check

The transferable skill here is not memorizing one S3 header. It is writing explicit storage contracts, bounding the actor and resource set, separating response evidence from state evidence, and refusing to turn an ambiguous mutation into an optimistic success. Those habits apply broadly to cloud application deployment and operations.

Refonte Learning's Cloud Development program currently describes a three-month format at 12–15 hours per week and lists competencies including cloud architecture, Docker, Kubernetes, Infrastructure as Code, cloud security, monitoring/optimization, DevOps methods, and cloud cost management; its curriculum also includes application deployment, CI/CD, and a cloud-application capstone. The page separately says no prior experience is required and basic programming is beneficial, while its admissions section states that applicants are working toward a bachelor's or higher-level degree; those are distinct published statements rather than an unconditional admission promise.

That broader curriculum is relevant because evidence-based cloud decisions require architecture judgment, deployment discipline, security boundaries, and operational verification together. The program page does not establish that this exact S3 conditional-write/versioning lab or Boto3 procedure is taught, so this article makes no such claim.