Skip to content

ObjectFS v0.11.0

Choose a tag to compare

@github-actions github-actions released this 03 Aug 15:11
· 123 commits to main since this release

POSIX completeness and write-path safety: the operations a user reaches for first — rm, rmdir,
mv, chmod — do the thing rather than returning an error or, worse, succeeding without acting. All
28 issues on the milestone closed before this tag was cut.

Two themes run through it. The first is that a configuration key should select something: nine
blocks that a loader had never read now reach the code they name, and the pricing region — read at
exactly one line, and only to label a summary — now picks the prices, so a mount in sa-east-1 no
longer reports us-east-1's rates under its own region's name. The second is that a fix is paired
with the mechanism that fails if it recurs, because most of what this release corrects was invisible
to the compiler, to vet, and to lint: a config key nothing reads, a cost figure nothing checks, a
gate that cannot fail.

This is the first release cut from the merge commit that closes its milestone, which is the rule
v0.10.3 established after two tags shipped without the work they were named for.

Added

  • rm and rmdir work. Unlink and Rmdir delete the object rather than returning EROFS
    (#163). The stub they replace was itself a fix — go-fuse defaults an unimplemented
    NodeUnlinker to success, so before it rm exited 0, the kernel dropped the inode, and the
    object stayed in the bucket billing with no path that reached it. Three details are load-bearing
    and are pinned by tests:

    • Deleting a file discards whatever the write path still holds for it. echo x > f; rm f is
      ordinary and the kernel does not guarantee a flush before the unlink, so a surviving dirty range
      would be PUT back by the next flush or by the unmount — the file returning from the dead at its
      written size, with no error anywhere.
    • rmdir refuses a non-empty directory with ENOTEMPTY. S3 has no directories, so removing a
      prefix's marker object while objects remain under it would succeed at the storage layer and leave
      every one of them present, billing, and unreachable through the filesystem.
    • A missing file is ENOENT, not success. The backend's DeleteObject no-ops a key that is not
      there — S3's contract — so absence is checked explicitly, and it is checked against both the
      bucket and the write path: Create records attributes without a PUT, so a just-created file is
      real and visible to stat with no object behind it yet.
  • mv works, and the README says exactly how far short of POSIX it falls. Rename copies
    server-side and then deletes, per object; renaming a directory moves everything under its prefix
    (#164). Before this, go-fuse's default for an absent NodeRenamer answered every mv with
    ENOTSUP. Six properties are load-bearing, each pinned by a test that was verified by mutation —
    the implementation was broken in that specific way and the test watched to fail:

    • Each source object is deleted only after its own copy has succeeded. An interruption
      therefore leaves the data at the old name, the new name, or both — never at neither. Duplicated
      data is an operator's cleanup problem; missing data is not recoverable, so the ordering is fixed
      in that direction deliberately, and a partial directory move is resumable by re-running the same
      mv.
    • The copy is server-side. Reading and rewriting through the process would make renaming a
      10 GiB file cost 20 GiB of transfer, and renaming a directory that times the object count.
      Objects above S3's 5 GiB single-part CopyObject limit route through UploadPartCopy.
    • The write path is flushed before the copy runs. A copy acts on objects, so a file whose only
      content is dirty ranges in memory is invisible to it: echo hi > a; mv a b would have copied a
      key that did not exist yet, deleted the source, and then flushed the pending ranges back to the
      old name — the file landing at neither name the user asked for.
    • The moved node is repointed, recursively for a directory. go-fuse's MvChild re-parents the
      same inode, so the FileNode survives a rename holding the path it was constructed with. A
      stored path is stale the moment a rename succeeds, and the consequence is silent in a way nothing
      would catch: after mv a b, a write to b flushed to a and recreated the source the rename
      had just deleted. A stale key is a valid key, so every S3 call succeeds.
    • Prefix matching is on a path boundary. mv dir dir-new must not move dir2/file. That is not
      a hypothetical spelling mistake — it is the same defect the cache's keyMatches had, found in the
      same audit.
    • renameat2's RENAME_EXCHANGE and RENAME_NOREPLACE are refused with EINVAL, not
      approximated.
      Both are atomicity promises copy-then-delete cannot keep, EINVAL is what the
      kernel and libc expect for an unsupported flag, and mv and Git fall back correctly on it. A
      foreign newParent is EXDEV, which is what go-fuse's own LoopbackNode answers.

    There is no atomic alternative to reach for. S3's RenameObject, added in 2026, is
    directory-bucket (S3 Express) only, and object annotations — which ObjectFS needs for POSIX
    attributes — are unsupported on directory buckets, so the two features are mutually exclusive. The
    README now carries a Rename is not atomic section stating what a concurrent reader can observe,
    what an interruption leaves behind, and which tools that breaks: anything relying on the
    write-temp-then-rename idiom for atomic replacement is not safe here between concurrent writers.

  • types.Backend.CopyObject, a server-side copy that preserves content encoding, content type,
    storage class, and user metadata. Each of the four is a requirement rather than a nicety, and the
    interface documentation says which failure each one prevents: the read path dispatches decoding on
    the stored Content-Encoding and fails closed on one it cannot handle, so dropping it would leave a
    compressed object permanently unreadable with its bytes intact; the storage-class default is
    STANDARD, so dropping it would silently promote the object out of the tier being paid for; and
    POSIX mode, ownership, and mtime live in user metadata and nowhere else, so dropping it would reset
    a file's permissions, which is not a thing rename does. Encryption is applied from configuration
    rather than copied from the source, so a key rotation reaches renamed objects.

  • internal/testaws: a DirectoryMarkerDelete capability probe, and RequireDirectoryMarkerDelete
    to skip on its absence. Deleting a dir/ marker while dir/child still exists panics the substrate
    emulator, because its object store is a filesystem abstraction where dir/ is the directory
    holding the child, and removing it orphans the child (filed as
    scttfrdmn/substrate#534, reproduced against
    afero alone with no substrate involved). In S3 a key is an opaque string and dir/ is an ordinary
    object, so this is the emulator's property and not ObjectFS's — which is the reason for a runtime
    probe rather than an assertion either way. The alternative was to assert the emulator's behavior as
    expected, which would encode a dependency's bug as this project's contract.

  • internal/storage/s3/copy_live_test.go (-tags=integration) covers the multipart copy path against
    real AWS, at real part sizes. It has no hermetic equivalent: substrate dispatches on
    x-amz-copy-source before checking uploadId, so it answers an UploadPartCopy as a whole-object
    copy with a 200 (substrate#532). That leaves the branch where a mistake is most expensive resting on
    nothing but reading — abandoned multipart parts are billed and invisible to ListObjects. Two
    shapes, both deliberate: a legal three-part copy with a short final part, which is where
    inclusive/exclusive range mistakes surface, and an illegal one that S3 rejects at
    CompleteMultipartUpload, which is how the abort path gets a failure arriving after every part has
    already uploaded.

  • A fuse: section in the configuration file, and it is the first one any loader has read
    (#180). direct_io, keep_cache, and sync_read. Nine fields on internal/fuse.MountOptions and
    internal/fuse.Config carried yaml tags for a whole release and were decoded by nothing, because
    config.Configuration had no fuse key at all — so a fuse: block in a config file was silently
    discarded, and the two flags that reach the kernel per-open were settable in Go and returned as the
    literal 0 from every Open. All three new fields default to false, false is the kernel's own
    behavior for each, and NewDefault therefore names no fuse section: the zero value is the
    default, and a second place for it to live is how the last set drifted.

    Each of the four seams between the YAML key and the value the kernel receives is now asserted by a
    test verified by mutation — the mapping was deleted or reverted to v0.10.0's code and the intended
    test watched to fail. That is the point of the change rather than a detail of it: every one of those
    nine fields was correct at the layer that declared it, and died at a boundary no test crossed. The
    adapter mapping in particular passed its package's whole suite with the three assignment lines
    removed, which is why it was extracted into a method that can be called without a mount.

    What the flags do to the kernel — whether a second read(2) at the same offset reaches the
    filesystem, whether cached pages survive open(2) — cannot be observed without /dev/fuse, so it
    lives behind a fuse_mount build tag with a make test-fuse-mount target. CI compiles the tag it
    cannot run, because a build tag nothing compiles is how four others in this repo came to carry code
    that does not build (#240). Those tests fail rather than skip when the device is absent: a test
    that skips itself reports success.

    Two of the four flags #180 nominated are not plumbable, and both reasons are recorded at the
    field that would have carried each rather than being dropped in silence. Splice: go-fuse only splices
    a ReadResult backed by a file descriptor, and this filesystem's reads come from S3 or from memory
    and return fuse.ReadResultData at every return site, so DisableSplice would disable a path never
    taken — a config key whose effect is provably nothing. The writeback cache: it maps to
    ExplicitDataCacheControl, which makes the filesystem responsible for invalidating the kernel's data
    cache, and there is not one NotifyContent, NotifyEntry, or NotifyInvalInode call in the
    repository — enabling it would convert bounded staleness into permanent staleness.

  • Subcommands: objectfs mount, objectfs unmount, objectfs version, objectfs help
    (#134). unmount is spelled both ways, since umount is what a decade of muscle memory types.

    objectfs unmount /mnt/s3 is the one that did not exist before and had to. Unmounting was
    previously "signal the mount process", which a systemd unit's ExecStop cannot do once that
    process is already gone, so the shipped unit called fusermount3 -u directly — a program that is
    absent on a minimal image and spelled fusermount on libfuse 2, and whose failure in either case
    reaches systemd as a bare exit status. The subcommand tries the libfuse 3 helper, the libfuse 2
    helper, umount, and finally umount(2), and when none works it reports which ran, which were not
    installed, and the lsof +D invocation that names whatever is holding the mount open. None of the
    candidates unmounts lazily or forcibly, and a test asserts that no candidate ever passes -z,
    -l, or -f: those detach the name while the filesystem keeps serving open files, so they report
    a finished unmount with writes in flight — and adding one would make every other unmount test pass,
    which is why the prohibition is a test rather than a comment.

    The form without a subcommand still works and is not deprecated. It is what every invocation
    written before this release looks like, including the ones in scripts nobody will revisit. A first
    argument carrying a URI scheme or a leading dash routes to mount; a bare word that is not a
    command is a usage error naming itself, so objectfs moutn s3://b /mnt does not become an attempt
    to mount a bucket called moutn.

    Flags now come before positionals, because Go's flag package stops parsing at the first non-flag
    argument — objectfs mount s3://b /mnt --foreground left --foreground as a third positional and
    silently did not apply it. Each subcommand gets its own FlagSet for the same reason:
    flag.CommandLine cannot parse a flag that appears after a positional at all.

    New: --mount-point, so a mount point can come from a flag instead of a positional, and
    --foreground, which names what already happens — ObjectFS does not fork, and the flag exists
    because init systems and scripts pass it and refusing it would break invocations that are correct
    about the behaviour. Exit codes are now defined: 0 succeeded, 1 the command was right and the
    operation failed, 2 the command line was wrong and nothing was attempted.

    main() is three lines around run(args, stdout, stderr) int, which is what makes any of this
    testable: it previously called log.Fatalf directly, and log.Fatalf calls os.Exit, which takes
    the test binary with it. cmd/objectfs therefore has a coverage floor for the first time (77%),
    replacing a note in .coverage-floors that recorded the package as untestable.

  • The systemd template unit mounts and unmounts the way the binary actually works (#135).
    configs/systemd/objectfs@.service now runs
    objectfs mount --config /etc/objectfs/%i.yaml --mount-point /mnt/objectfs/%i --foreground and
    stops with objectfs unmount /mnt/objectfs/%i. What it replaces was valid systemd and wrong in
    four ways: ExecStart=... s3://%i /mnt/objectfs/%i made the instance name and the bucket name one
    string, which fails for a prefix, for two mounts of one bucket, or for a bucket whose name is not a
    legal unit instance; ExecStop=/bin/fusermount3 -u is the single-helper call described above;
    Restart=always remounted a filesystem after a clean systemctl stop; and RequiresMountsFor on
    the unit's own mount point asked systemd to wait for the mount this unit creates. TimeoutStopSec
    is now stated rather than inherited, because that is the flush window — SIGTERM makes the mount
    process unmount, which writes buffered ranges to S3, and too short a value there is a SIGKILL
    through buffered data.

    Two gates, checking different things. TestSystemdUnit* in internal/config parses the unit's
    Exec* lines through the same parser the documentation gate uses and checks every subcommand and
    flag against the sets scraped from cmd/objectfs/main.go — so a flag renamed in the binary breaks
    the unit's test, and no list is maintained by hand. A systemd-unit CI job additionally runs
    systemd-analyze verify on objectfs@example.service for the half a Go test cannot check. Neither
    alone would have caught the old unit: systemd-analyze passes it, and a Go test cannot tell whether
    RequiresMountsFor means what its author thought.

    Found while writing the Go half: joining \ continuations is load-bearing. A loop over raw lines
    stops at ExecStart=... \ and skips everything after it, so --mount-point and --foreground went
    unchecked — verified by mutation, changing --mount-point to --mountpoint left the test passing.

  • Region-aware S3 pricing, generated from AWS's published price list rather than typed in (#161).
    internal/awsrates now holds 36 regions × 8 storage classes × 6 rates, produced by
    go generate ./internal/awsrates/... from the public per-region offer files. Those files need no
    credentials
    , so anyone can refresh every number in one command, and the accessors are
    ForRegion(region, class) and AllForRegion(region) — with the us-east-1 forms kept for callers
    comparing tiers, where only the ratio matters.

    Nothing on the mount path fetches anything: the table is compiled in, so pricing a tier needs no
    network and cannot fail a filesystem operation. That constraint is what makes the whole approach
    usable, and a test clears every AWS credential environment variable and asserts it holds.

    The offer file, not the Pricing API, is the source. The API needs credentials and would pull
    aws-sdk-go-v2/service/pricing into the module, whose transitive requirement moves smithy-go
    under the S3 client that serves every read and write — dependency risk on the data path in order to
    price a tier. The offer files also avoid three traps the API presents, each verified against live
    data rather than assumed: productFamily is absent from 315 of us-east-1's 381 S3 products, so
    filtering on it silently drops SKUs; filtering Deep Archive storage by volumeType returns a
    staging SKU at 21× the real rate; and us-west-2 is not a pricing endpoint, but the SDK's
    resolver templates any well-formed region into an opaque DNS failure rather than saying so.

  • internal/awsrates/offerfile, the extraction rules as ordinary tested Go rather than a script
    someone ran once.
    Every rule in it exists because the obvious version returns a plausible number
    from the wrong SKU, and each has a test named for the case that forced it:

    • The region's usagetype prefix is derived, never assumed. USE1, USW2, APS8 are not region
      codes and have no published mapping, so the prefix is recovered structurally from the Standard
      storage product. us-east-1's prefix is the empty string, which is a case in its own right: a
      derivation bug returning "" everywhere looks correct there, and us-east-1 is the default region
      and the fallback for every unknown one.
    • Suffixes match exactly, never with strings.HasSuffix. Tables-, Annotation-, Files- and
      Vectors-TimedStorage-ByteHrs all end in the Standard storage usagetype and all cost more. Found
      by mutation: swapping the exact comparison for a suffix match survived the entire suite, because
      the Standard query is shielded by its own volumeType clause. A probe of all 27 lookups found the
      single query where the exact match is load-bearing — Intelligent-Tiering storage, where
      Tables-TimedStorage-INT-FA-ByteHrs sits at $0.0265 against the correct $0.023 — and that is now
      the case the test asserts.
    • An ambiguous query is an error, not a coin flip. Where two SKUs on one query publish different
      prices at the same band, extraction fails and names both SKUs, rather than returning whichever the
      map iteration reached.
    • Egress comes from the AWSDataTransfer file, keyed on fromLocation. S3's own
      DataTransfer-Out-Bytes usagetype is the Multi-Region Access Point routing charge, not internet
      egress, and the transfer file publishes a $0.00 free-tier SKU on the same four attributes as the
      real one — so taking the lowest match prices every byte leaving the region as free.

    The package went from no tests to 89.9%, internal/awsrates from 76.2% to 100%, and the generator
    from no tests to 74.4%. internal/awsrates/offerfile/offertest builds the fixtures all three suites
    share, so a rule is stated once rather than transcribed per suite.

Fixed

  • The compressed-upload bypass is pinned by a test that asserts the routing, not just its result
    (#153). The corruption itself was fixed in 0.10.1 — a compressed object no longer goes through the
    CargoShip transporter, which cannot set Content-Encoding — but the test covering it asserted only
    that the stored object carried the header. That is the property users need and it is one step removed
    from the mechanism: it would also pass if the transporter had acquired header support, and it would
    keep passing if the bypass were replaced by anything else that happened to produce a correct object.

    The new test asserts which upload path ran, using the cargoship-created-by metadata the transporter
    stamps on everything it uploads. It has a control half that is equally load-bearing: a 1 KiB object,
    below the compression threshold, must carry the stamp. Without that half the test would pass on a
    build where the transporter never runs at all, silently measuring a disabled feature instead of the
    bypass. Both halves were verified by mutation — removing the bypass fails the assertion, and disabling
    CargoShip fails the control.

    Filed upstream as scttfrdmn/cargoship#353: Archive has no field that maps to Content-Encoding,
    and neither transporter sets the header, still true in v0.20.0. CompressionType looks like the field
    and is not — buildMetadata puts it in user metadata. Until that lands, ObjectFS gives up CargoShip's
    throughput for exactly the objects that compressed.

  • Two gosec findings the security check reported but lint did not. There are two gosec runs in
    CI reading different suppression directives: golangci-lint's honors //nolint:gosec, while the
    standalone gosec whose SARIF becomes GitHub code scanning honors only #nosec. Both sites already
    carried a reasoned //nolint, so lint passed at 0 issues and the gosec check failed with two new
    alerts. Neither finding is real — the generator writes committed Go source holding published list
    prices, and the unmount helper spawns no shell, takes its program from a fixed platform table, and
    passes the mount point as one argv element — so both now carry #nosec alongside, with a note that
    the duplication is about two tools rather than two risks. Verified by installing the same gosec the
    workflow uses, reproducing both findings, and confirming an unsuppressed 0o644 write in the same
    function is still reported, so the suppression is line-scoped rather than file-wide. Six other sites
    have the same gap and are open code-scanning alerts today; filed as #264 rather than swept, since
    each needs its own judgment about whether the finding is real.

  • pricing.region selected nothing, so every cost figure was us-east-1's, labeled with whatever
    region the operator configured
    (#161). The rates lived in a map built at package init, and package
    init cannot see a configuration. PricingConfig.Region was read at exactly one line in
    internal/storage/s3 — a summary field — while every number came from the region-blind map, and the
    assertion covering it compared a rate to itself, so it passed for eleven releases.

    That is worse than an unlabelled figure. region: sa-east-1 above us-east-1 prices reads as correct,
    and sa-east-1 storage is 76% more expensive than us-east-1's — an operator sizing a deployment
    there was reading a number 43% below what they would be billed. The spread across the fleet is
    material in both directions: Standard runs $0.0225/GB-month in ap-east-2, $0.023 in us-east-1 and
    us-west-2, $0.0245 in eu-central-1, and $0.0405 in sa-east-1.

    Rates are now generated for 36 regions × 8 storage classes × 6 fields from AWS's public price
    list offer files, and PricingManager resolves each lookup through the configured region. A region
    with no published table falls back to us-east-1 and says so — one warning at construction naming
    the configured region, the region actually used, and what to do about it, rather than one per object
    access. PricingSummary now carries both, so a cost report cannot label us-east-1's numbers with a
    region that produced none of them.

    StorageTierInfo.CostPerGBMonth is gone rather than corrected; see Removed.

  • Glacier's PUT price was the price of thawing an object, 67% too high. Requests-Tier3 at
    $0.00005 is RestoreObject; a Glacier PUT is Requests-GLACIER-Tier1 with operation: PutObject at
    $0.00003. The cause is worth recording because it is not arithmetic: usagetype is not a unique
    key for an AWS rate.
    Requests-Tier3 carries three SKUs at two prices, Standard-Retrieval-Bytes
    two at two, and Requests-GLACIER-Tier1 fifteen at two, separated only by the operation attribute.
    A query that omits it returns whichever price Go's map iteration reached first — a wrong number that
    changes between runs.

    The integration test that existed to catch exactly this agreed with the defect, because it spelled
    the same query a second time by hand. Two transcriptions of one intent check each other, not the
    intent. It now drives its queries from the single place that defines them and compares the whole
    committed table against a fresh extraction, field by field.

  • A bucket name one character long reported "is 1 characters". s3://b is what someone types
    while testing, so the singular arm is a message operators read, and a grammatical error in an error
    message reads as a message nobody has looked at. The test now asserts both arms of the sentence
    rather than the substring after them.

  • The read-ahead trim is covered by tests rather than by luck. inflightFetches.unclaimedStart
    and the arm of performPrefetch that drops a prefetch whose whole range is already in flight had no
    test of their own. Both are reached only when a read is outstanding at the instant a prefetch is
    scheduled, so an idle machine ran them by accident and a loaded one did not: internal/fuse measured
    67.5% alone and 66.4% under go test ./..., and the coverage gate failed on a commit that touched
    neither file. No behavior changed here — the point is that a branch nothing owns is a branch a
    refactor can delete in silence, and this one prevents a sequential read from paying for the same
    bytes twice.

    The drop arm is asserted with an explicit timeout rather than a byte count, which is what removing it
    actually does: a prefetch trimmed to a non-positive length waits on the very read it was trimmed
    against, so the failure is a parked prefetch worker, not an over-large GET. With every worker parked
    the read-ahead stops entirely and nothing reports it.

  • A data race between ConsensusEngine.Stop and an inbound heartbeat. Stop read
    ce.electionTimer without holding ce.mu while resetElectionTimer was replacing it from the
    gossip receiver goroutine, which is where an AppendEntries RPC is handled. Neither shutdown signal
    ordered the two: the receiver does not watch the consensus engine's stopCh, and although
    ClusterManager.Stop stops gossip first, GossipProtocol.Stop closes the socket without waiting
    for the receiver, so a message already inside a handler keeps running. Start's unlocked call to the
    same function was the second instance. The regression test drives handleNetworkAppendEntries
    concurrently with Stop rather than over UDP, because the two tests that caught this in CI hit it
    only when a heartbeat happened to land inside Stop's window — reproducible under CI's load and not
    locally, which is the flake shape a -race gate is worst at.

  • Changing compression.algorithm no longer orphans every object already in the bucket. A mount
    now decodes any algorithm ObjectFS can write, chosen from the object's stored Content-Encoding
    rather than from the configuration (#230). Before this, Compressor held exactly one codec and
    Decompress compared the stored encoding against that codec's token, so a mount could read back
    only what it was currently configured to write. Switching zstd to lz4 made every existing zstd
    object unreadable — and so did setting enabled: false, which is how an operator turns compression
    off after deciding the read amplification was not worth it. Turning compression off stops new
    objects being compressed; it does not make the existing ones uncompressed, and it was the change
    most likely to be made and least likely to be expected to break anything.

    Nobody got wrong bytes: the read failed closed with a DATA_CORRUPTION error, because
    checkFullyDecoded cross-checks the decoded length against the recorded objectfs-original-size.
    That guard was compensating for a dispatch that could have succeeded — every codec was already
    linked into the same binary. The decoder table is built from
    pkg/compression.SupportedAlgorithms rather than listed by hand, so an algorithm added there is
    readable without a second edit; that derivation is what stops the defect's actual shape, which was
    a set of encoders and a set of decoders maintained independently. Pinned by the full
    write-algorithm × read-configuration matrix, including a disabled reader, and the fail-closed
    behavior still holds for the cases no dispatch can help: a Content-Encoding naming a coding
    ObjectFS does not implement, and a header stripped after the write by a CopyObject or a tier
    transition. A body its own declared codec rejects is now reported as non-retryable corruption
    rather than a bare error the retry layer would take at face value.

  • A mount on STANDARD_IA, ONEZONE_IA, or GLACIER_IR could not create anything at all.
    mkdir and touch both failed, and so did writing any file smaller than 128 KiB (#154). AWS's
    per-tier minimum object size is a billing floor — S3 stores a zero-byte STANDARD_IA object and
    bills it as 128 KiB — but TierValidator.ValidateWrite enforced it as though S3 would reject the
    write, and it is called before anything else in PutObject. Both of the ways this filesystem
    brings a name into existence go under that floor: Mkdir writes a zero-byte marker object so an
    empty directory is distinguishable from a prefix that never existed, and a Create followed by a
    small write flushes a small object. So the three tiers most of the cost documentation recommends
    were the three a filesystem could not be used on, and an IA-tier integration test could not get
    past its own setup.

    It is a warning now, naming the size written alongside the size that will be billed — which is the
    actionable fact, since a tier that bills every object as 128 KiB is more expensive than STANDARD
    for a workload of small files, and nothing downstream would have mentioned it. What still refuses
    a write is tier_constraints.min_object_size: an operator who sets that has asked for a floor that
    is not AWS's, and a policy someone chose is the only kind worth enforcing. Note the consequence of
    the split, which is tested rather than left to be rediscovered — setting that key to the tier's own
    published minimum reinstates exactly the old gate, zero-byte directory markers included.

    The gate is enforced two layers below the operation that trips it, so it is pinned at both: the
    validator's own tests assert a zero-byte write is accepted and that the billing warning carries
    both numbers, and a test in internal/fuse drives real Mkdir and Create calls against a real
    endpoint on every tier that has a minimum, reading the tier list from StorageTiers so a class
    that gains one later is covered without editing the test. Only the second layer establishes that a
    mkdir is a zero-byte PUT, which is the step that turned a billing gate into an unusable mount.

  • chmod and automatic tier transitions worked on every key except the ones containing a +.
    x-amz-copy-source is read by S3 as a URL path, and url.PathEscape leaves + as itself while S3
    decodes + in that header as a space — so a self-copy of a+b.txt asked for a b.txt and came
    back 404 NoSuchKey. Both callers are operations a user expects to be invisible, so the symptom was
    a chmod failing with ENOENT on a file that plainly exists, and a storage-tier transition failing
    on a timer with nothing to attribute it to. A + in a filename is ordinary: version numbers, C++
    sources, and any timestamp written as 2026-08-01T00:00+00:00. Escaping is now in one place
    (Backend.copySource) rather than open-coded at each call site, one of which built the header with
    no escaping at all. Verified against real S3 in us-west-2 rather than reasoned about — both
    url.PathEscape and (&url.URL{Path: …}).EscapedPath() fail on such a key, %2B succeeds, and
    every other character PathEscape passes through (~ * ( ) $ & = @ :) was probed on the same
    endpoint and copies correctly.

  • objectfs stats reported zero for six counters that were being maintained correctly all along.
    GetStats copies field by field, and its list named nine of fifteen fields: Creates, Deletes,
    and Renames, each incremented by its own operation, and the three latency averages, each
    maintained as an exponential moving average by recordReadTime and its siblings. All six were live
    and none reached the snapshot. This is a whole class of quiet defect — a field added to Stats and
    not added to the copy is not a compile error and not a test failure, just a number that reads zero
    forever — so the guard is a reflection test that sets every counter to a distinct non-zero value
    and asserts the snapshot reports each one. Distinct, so that a copy assigning the right field from
    the wrong source is caught too, which naming them all 1 would not be. An enumeration of field
    names would have had the same failure mode as the code it checks. time.Duration's reflect kind is
    Int64, which is how the three latency fields were found.

  • README.md: the not-implemented table still listed unlink and rmdir as EROFS and the
    tools-that-do-not-work list still said mv fails with ENOTSUP "because there is no rename" — both
    true when written and both false since. A row asserting an operation fails is as wrong as a row
    naming the wrong errno once the operation works, and it misleads in the worse direction: a reader
    avoids something that would have worked. internal/fuse/unimplemented_test.go is the mechanism for
    the errnos in that table, and rename's departure from it is now pinned from the other side — a test
    asserts the bridge dispatches to Rename rather than reaching go-fuse's ENOTSUP default, which
    is what a drifted signature or a build tag excluding rename.go would silently restore.

  • Eight documents outside the README described the pre-rename filesystem, and four of them told
    users an operation fails that works
    (#162). docs/architecture/overview.md listed unlink,
    rmdir, and rename as unimplemented and said rm returns EROFS; docs-platform/guide/
    told users mv fails with ENOTSUP "because there is no rename"; the playground's benchmark
    script worked around rm by shelling out to aws s3 rm. Every one was an accurate description of
    v0.10.3 being read by users of a version where those operations work — understating rather than
    overstating, which is friendlier and still wrong, because it sends people to build workarounds for
    a problem that is fixed.

    Two mechanical gates now cover the class, because it has gone stale twice in the same place
    (internal/config/docs_posix_test.go):

    • No document may state an operation count. Eight files said "roughly 10 of ~40 VFS operations
      are implemented", each having copied it from the audit that measured it once; six operations
      landed across three releases and not one sentence changed. This is the version-constant problem
      exactly — one number, many copies, no way for a copy to learn it is wrong — so it gets the same
      answer: say a subset is implemented and point at the table. CHANGELOG.md is exempt, with the
      reason recorded in the code: a released section is an immutable record of what that release did,
      and editing its counts to match today would falsify the record.
    • The README's "Not implemented" table may not name an operation whose go-fuse interface
      internal/fuse asserts.
      It reads the _ fs.NodeUnlinker = (*DirectoryNode)(nil) assertions
      rather than the method set, because the assertion is what makes support real — go-fuse probes each
      interface with a type assertion and substitutes a default when it is absent, and for Unlink and
      Rmdir that default is success. A method with a drifted signature compiles and is silently
      never called; the assertion is what fails.

    The tempting third gate — flag any line pairing an implemented operation with a refusal errno,
    repo-wide — was written, measured, and rejected: ten hits, of which eight are changelog entries
    correctly describing past releases. A gate whose output is 80% false gets deleted. Both surviving
    gates were verified by mutation, and the first one's word list is written out in full because a
    first draft with ten|twenty|thirty|forty passed on "sixteen of forty VFS operations" — a narrow
    pattern that passes is indistinguishable from a correct repository.

  • internal/filesystem/interface.go says what it is: a design sketch with no importers anywhere in
    the tree
    , whose only implementation is its own test mock. It reads like a capability list — it
    declares Rename, Truncate, Chmod, Chown, Link, Symlink, Readlink, four xattr methods,
    and Statfs — and a reader taking a method there as evidence of support would be wrong about
    several. That is not hypothetical: internal/vfs's FileType comment already records this
    interface advertising Symlink and Link with nothing behind them as what went wrong in v0.10.0.
    Kept rather than deleted because the multi-protocol work it sketches is tracked (#181) and this is
    the record of its original shape.

  • write_buffer.max_memory is enforced. It was declared in the config schema, defaulted to
    "512MB", validated as a size string, and read by nothing (#205) — so every mount since the key
    appeared reported a write-buffer ceiling and enforced none, on the one path that holds user data in
    memory before it is durable. The bound reclaims before it refuses: at the ceiling with flushable
    data it flushes and accepts, because a limit that turned legal writes into ENOSPC would be worse
    than the unbounded growth it replaced — with the shipped 512 MB default that would mean failing
    every workload writing more than 512 MB in total. A single write larger than the entire limit is
    admitted, since write(2)'s ENOSPC means "filesystem full" and a caller retrying it would get the
    same answer forever. A refusal surfaces as ENOSPC through vfs.ErrNoSpace, not as EIO.

  • A single file can grow past the write buffer's memory bound. Reclaiming flushes other keys and
    deliberately skips the one being written, since its pending writes are about to be extended and
    uploading them now guarantees a second upload moments later. As the only rule that made the bound
    refuse the most ordinary write there is: a program appending to one file has no other key to flush,
    so at the shipped 512 MB default, writing any file past 512 MB failed at exactly 512 MB with
    ENOSPC — sequentially writing a large file being the workload ObjectFS exists for. The target key
    is now flushed as a last resort, which is what streaming a large file through a bounded buffer
    looks like; a test writes a file to eight times its limit and asserts both that every write
    succeeds and that the resulting object is whole, so a lossy reclaim fails rather than passing
    quietly.

  • A cache that answered a ten-byte request with two bytes now reports a miss (#178). The
    types.Cache contract is that a partial hit is a miss, and it is a contract about data integrity
    rather than about return values: internal/fuse passes a non-nil hit to the kernel verbatim as file
    content, so a short answer is a truncated read reported as a successful one, and the caller cannot
    distinguish a short cache entry from a short file. The Redis implementation used GETRANGE, which
    clamps to the stored value's length and returns what it can — GETRANGE k 8 17 over a ten-byte value
    answers with two bytes and no indication that eight are missing. It had ten tests of its own, all
    passing, none of which asked for a range longer than what was stored.

    What found it is the durable part: internal/cache/cachetest is a shared conformance suite that
    every types.Cache implementation is now run against.
    There were five implementations, one
    contract, and no test in common — each was checked against the questions its own author thought to
    ask, which is why four of them satisfied a rule the fifth violated in the most consequential
    direction. Ten cases, each stating in its failure message what a caller would observe: exact-range
    hits, straddling and past-the-end reads as misses, a request longer than the entry as a miss, the
    open-ended size <= 0 form, the returned slice not aliasing the cache's own storage, a newer Put
    winning where it overlaps, and Delete removing the key it names and nothing that merely shares a
    prefix with it. A sixth implementation is one enrollment away from being held to the same contract.

Changed

  • One size parser reads every size in a configuration file (#159). pkg/utils.ParseBytes is
    now the only implementation; the three surviving copies — internal/compression.parseSize,
    internal/config.parseOptionalSize, and a fourth in tests/unit_test.go — are deleted, and
    utils.ParseOptionalBytes handles the unset-means-zero case identically everywhere. Every size a
    config file names is therefore validated at load with a message naming the YAML key, and no size is
    substituted silently.

    Four parsers were four answers to the same string, and the disagreements were not cosmetic. Each
    one is verified by running the deleted code rather than by reading it:

    • internal/compression's stopped its unit table at GB, so min_size: 1TB was an error while
      1GB worked; it accepted -1MB as a negative compression floor, which makes
      len(data) < c.minSize false for every input and compresses everything including the bytes the
      threshold exists to skip; and 99999999999GB overflowed to math.MaxInt64, the same defect
      inverted — a floor nothing is ever below, so compression is configured on and never happens.
      Neither reported anything.
    • The copy in tests/unit_test.go fell through to strconv.ParseFloat, which accepts Go float
      syntax: InfMB parsed as math.MaxInt64 and 1e3MB as 1000 MB. It also rejected 1TB. A test
      asserting against a private copy of a parser is a test that agrees with itself — this one passed
      while disagreeing with the parser the mount used.
    • internal/adapter's, removed earlier in this release, returned 1 GiB and no error for anything
      it could not parse.

    ParseBytes is strict for the reason the loader is strict: it rejects trailing garbage (4KiB,
    the spelling someone who knows the units writes), negatives, Inf/NaN, exponent and hex-float
    notation, and any value that overflows int64 once multiplied. The empty string is the one case
    with a second meaning, and ParseOptionalBytes is where it lives — unset means zero, which is the
    caller's signal to use its own default. It deliberately does not distinguish "" from a literal
    "0", because no caller in this repository does.

  • Each listener's address is one setting, beside the enabled flag that governs it
    (#202, #211, #212).
    global.metrics_port, global.health_port, global.profile_port,
    monitoring.metrics_addr, monitoring.health_check_addr and monitoring.enable_pprof are all
    removed, replaced by monitoring.metrics.addr and monitoring.health_checks.addr, both defaulting
    to loopback127.0.0.1:8080 and 127.0.0.1:8081. Same ports, so an existing same-host
    Prometheus scrape keeps working; the host is what changed.

    A port and an address were never two settings. monitoring declared the two addresses, defaulted
    them, documented them — and read neither, while the ports two sections away were what the listeners
    used. So an operator who set health_check_addr: 127.0.0.1:8081 to keep an unauthenticated
    diagnostic endpoint off the network got a wildcard bind and no warning: the setting that would have
    changed it was inert, and the setting that was live could not express a host at all, because the bind
    was fmt.Sprintf(":%d", port). Both endpoints are on by default, so a stock
    objectfs s3://bucket /mnt published per-operation counts, error rates, sizes and timings — and, on
    /health, component names and error strings — to anything that could route to the host.

    An address subsumes a port, so keeping both would have preserved the disagreement. It also settles
    what a port could not: health_port: 0 disabled the health endpoint while metrics_port: 0 was
    treated as unset and defaulted back to 8080 and bound it, so two adjacent fields spelled "off"
    differently and the metrics one failed in the direction that leaves a port open. There is no 0 in
    an address, and each listener already has an enabled flag next to its new addr.

    global.enable_pprof and global.profile_port are removed rather than wired. Nothing read either.
    The one pprof server in the tree is pkg/profiling's, which has no importer, also binds every
    interface, and serves mutating /memory/gc and /memory/free handlers with no authentication —
    binding a third unauthenticated listener inside the change that stops binding two of them was the
    wrong trade to make on the strength of a boolean nothing read. Its fate is #245.

    Three further consequences:

    • A bind failure now fails startup and names the address. Both servers used to bind on a
      goroutine and log, so a mount whose metrics port was taken came up with no endpoint and one line
      in the log to say why — an operator finds that out when a probe starts failing. This deliberately
      contradicts #192's reasoning that non-fatal was "the right call for observability":
      enabled: false is already how you ask for no endpoint.
    • Validation catches what a listener reports badly. net.SplitHostPort accepts "99999", so
      the port range is checked explicitly and the error names the field. health_port: 99999 used to
      reach net.Listen from YAML unchecked.
    • OBJECTFS_METRICS_PORT/OBJECTFS_HEALTH_PORT become OBJECTFS_METRICS_ADDR/_HEALTH_ADDR,
      and OBJECTFS_METRICS_ENABLED — documented in two places and assigned by nothing, which is
      #202's shape in the setting that closes an endpoint rather than the one that moves it — is now
      wired, along with a new OBJECTFS_HEALTH_ENABLED. Both parse strictly: a value that is not a
      boolean fails startup naming the variable, where the feature-flag variables coerce anything but
      "true" to false. These two govern unauthenticated endpoints that default to on, so silent
      coercion is wrong in whichever direction it picks.
    • The endpoints are documented. grep -rn health_port docs/ README.md configs/ examples/ used to
      return nothing: the knobs existed, were read, changed behavior, and appeared in no shipped
      documentation or example config (#192). The README now has a Metrics and health endpoints
      section with both addresses, the curl that reaches each, the environment overrides, and why the
      defaults are loopback; docs/index.md names the addresses beside the features rather than listing
      "health monitoring" with nowhere to point a probe.

    The test gap is the more interesting half. TestStartMetricsBindsTheEndpoint scraped 127.0.0.1
    and passed against a wildcard bind, because a wildcard bind answers on loopback too — so the tests
    asserted that something was listening and never that it was listening where the configuration
    said. Collector.Addr() now reports the bound address, and the regression tests assert two things:
    that it equals what was configured (a wildcard bind reports 0.0.0.0 or [::] here), and that the
    endpoint does not answer on a routable non-loopback address of the host. Verified by mutation —
    restoring the ":"+port bind fails both halves while the old-shaped test stays green.

  • Compression is configured under storage.s3.compression, not write_buffer.compression
    (#157).
    Nothing has ever compressed a write buffer. The block always configured the codec the
    S3 backend applies to a whole object on its way to the wire, and the misplacement mattered in both
    directions: an operator tuning the write buffer was changing how objects were stored, and an
    operator looking for how objects are stored had no reason to read the write-buffer section. It now
    sits under the backend that applies it. Defaults are unchanged — enabled: false, zstd, level 3,
    min_size: 4KB.

    write_buffer.compression and performance.compression_enabled are removed rather than
    deprecated
    , so a configuration file still setting either fails to load with the offending key
    named. That is deliberate, and follows the precedent set by the security.encryption booleans
    removed in v0.10.1: a key kept as an ignored field means an operator's compression settings
    silently stop applying on upgrade, which is the same failure as the unknown keys strict decoding was
    introduced to catch, arrived at by a different route.

    performance.compression_enabled is the more instructive of the two. It defaulted to true, was
    read by nothing, and sat two sections away from the real setting that defaulted to false — so
    the shipped configuration contained a prominent compression_enabled: true while no object was ever
    compressed, and anyone who read the file to find out came away with the opposite of the truth. It is
    removed rather than wired up because compression happens in the S3 backend, on the object, and a
    second boolean over one feature can only ever disagree with the first. OBJECTFS_COMPRESSION_ENABLED
    survives and now assigns storage.s3.compression.enabled: the variable's name was never wrong, only
    what it assigned to, and exporting it previously had no effect on whether anything was compressed.

    One assertion in the mapping test had to change with it, for a reason worth recording: it asserted
    Algorithm: "zstd", which is also the default — so a buildS3Config that hardcoded "zstd" and
    ignored the configuration passed. Verified by making exactly that mutation. The test now uses lz4,
    because every value in a mapping test has to differ from the value the field would hold if the
    mapping were absent. That is the shape of the original config-plumbing defect: a field nothing mapped
    still arriving at a plausible value from somewhere else.

  • performance.read_ahead reaches the prefetcher, and has five keys instead of twenty (#176).
    Every read-ahead setting was decoded, defaulted, range-checked at load, documented on its own page,
    and shipped in four preset config files — and read by nothing, because the mount constructed its
    read-ahead manager with a literal nil and ran that manager's built-in defaults. So a deployment
    that set window_size: 128MB for a streaming workload was prefetching 64 KB, and had no way to find
    that out.

    The reduction is the fix, not a simplification of it. The two sides did not disagree about a value;
    they disagreed about what read-ahead is. internal/config described a strategy selector
    (strategy: simple|predictive|ml) over a pattern detector with a confidence threshold and a
    prediction window, a bandwidth-capped prefetcher, and an online-learning model with
    ml_model_path, learning_rate, pattern_depth and model_update_interval. What exists in
    internal/fuse is a sequential-access detector with a prefetch window, five fields, tuned against
    measured byte counts. Beyond enabled there was no field-name overlap at all — nothing to pass
    through — so the block was cut down to the detector's own knobs and wired:
    enabled, window_size, min_sequential, concurrent_reads, ttl.

    Wiring the old block would have been worse than leaving it inert. A validated ml_model_path
    reaching no model loader is a claim about the software, and range-checking learning_rate to 0–1 is
    what made the whole set look load-bearing: a user whose config is rejected for an out-of-range value
    reasonably concludes the accepted values do something. Fifteen keys are removed rather than
    deprecated
    , so a file still setting one fails to load with the key named — same reasoning as
    write_buffer.compression above.

    performance.read_ahead_size is removed too, and it is compression_enabled's twin: a prominent
    64MB default, read by nothing, sitting two lines above the block describing the same quantity with
    a different default. Two names for one setting can only ever disagree.
    OBJECTFS_READ_AHEAD_SIZE goes with it, and the six OBJECTFS_READAHEAD_* variables become four —
    the two counts now report a parse failure rather than silently keeping the default, because a worker
    count reverting to 4 when 1 was meant is prefetch traffic nobody asked for.

    Behavior at the default configuration is deliberately unchanged: config.NewDefault's block is now
    exactly fuse.DefaultReadAheadConfig, which is what every mount has run all along, and tests on both
    sides of the seam assert those two remain equal. Two validation rules are new because the values now
    reach code — concurrent_reads: 0 is rejected (it is the worker count, and zero starts no workers,
    so every prefetch is queued and never performed: read-ahead silently off while the config says on),
    and an empty window_size is rejected when enabled (an empty floor is a floor of zero, not the
    default). A disabled block is no longer validated at all, which is the same defect pointing the
    other way: a mount should not be refused over settings nothing will read. Two checks stay
    unconditional, because they catch a typo rather than a setting: a window_size that is not a size at
    all, and a ttl written without a unit — ttl: 5 is five nanoseconds, silently, since yaml.v2 reads
    a bare integer into a time.Duration as a raw nanosecond count. Both would otherwise surface months
    later, when read-ahead is turned on, as a validation failure over a line nobody touched. The ttl
    omission was found by the reflection walk over the schema that pins every duration to
    validateDurations, the moment this change gave read-ahead a duration at all.

    Presets and docs were rewritten rather than relabeled. readahead-simple.yaml became
    readahead-disabled.yaml — it configured "no pattern detection, no prefetching", whose honest
    spelling is read-ahead off — and readahead-ml.yaml was deleted, because a preset cannot be
    corrected into configuring a model loader that does not exist. docs/features/read-ahead.md lost its
    ML training guide and its three-strategy comparison for the same reason.

    One thing the wiring exposed and did not fix: min_sequential has no effect below 6, because the
    prefetch also requires a confidence above 0.5 and confidence is sequentialHits/10 — two thresholds
    over one counter, of which one is configurable. The shipped default of 3 is inside that range, so the
    documented default does not describe the default behavior. Reconciling them changes prefetch
    behavior at the default configuration, which wants a measurement rather than a number nudged in
    passing, so it is filed as #247 and documented where the setting is.

  • The cluster.redis block selects the cache a mount uses, and the cache block reaches it
    (#178).
    cache.NewFromConfig — the only reader of cluster.redis.* anywhere — had no caller.
    The adapter built a MultiLevelConfig literal of its own instead, so seven cluster keys plus a
    seven-key redis sub-block were decoded, defaulted, validated and documented while no mount
    consulted any of them: a deployment that configured a shared Redis cache got a private in-process
    one, with no error and no warning, and looked correct until two nodes disagreed about a file.

    Both halves of that mistake are fixed together, because they are the same mistake. NewFromConfig's
    other arm passed a literal nil to NewMultiLevelCache, discarding the L1/L2 sizing, the TTL, the
    persistent-cache directory and the eviction policy its argument carried — so even with a caller, most
    of the cache block would still have been ignored. The mapping now lives in internal/cache beside
    the selection rather than in the adapter, since a second copy of it is how the two came to disagree
    in the first place, and Adapter.cache is typed as types.Cache: naming the concrete
    *cache.MultiLevelCache there is what made the function uncallable, as the field could not hold what
    it returns.

    An unreachable Redis now fails the mount rather than falling back to an in-process cache. Falling
    back is this same defect one layer out — both nodes come up, both believe the cache is shared, and
    nothing in either log explains the disagreement — so the error names cluster.redis and the mount
    does not start. This is the third instance of the shape #156 and #176 were: a config block whose
    every layer worked except the one that had to call it.

  • storage.s3.cost_optimization keeps one key, and it does what it says (#203). The block had
    six; five are removed and one is new. small_objects_on_standard stores an object on STANDARD when
    the configured storage_tier would bill it as larger than it is and STANDARD is genuinely cheaper
    for it. Defaults to false, because it changes the storage class objects are written with, and an
    operator who set storage_tier should get that tier until they ask otherwise.

    Removed, not deprecated — configuration is decoded strictly, so a file still carrying one of these
    fails at startup naming the key rather than silently ignoring it:

    Removed key Why
    enabled Gated nothing. The backend has no such field
    tiering_enabled Automatic tier transitions exist in the S3 backend; nothing on the mount path invokes them
    lifecycle_enabled Lifecycle rules are a PutBucketLifecycleConfiguration call this backend never makes
    transition_to_ia Same: a lifecycle rule, never written
    transition_to_glacier Same

    This is the fourth instance of the shape #156, #176 and the cluster.redis item above were, and
    the most direct: internal/config.S3CostOptimization and internal/storage/s3.CostOptimization
    shared no field name at all, so the block could not be mapped even in principle. buildS3Config
    carried a comment saying it was not mappable, which was true and is not a fix — the two types had
    drifted until the only honest options were to plumb a field that did not exist on both sides or to
    delete what nothing read.

    Two more s3.CostOptimization fields survive as Go struct fields with no YAML key, and the
    distinction is deliberate: EnableAutoTiering and CostThreshold are read by code an embedder can
    call directly, while a mount has no path to it. MonitorAccessPatterns is likewise unmapped, and
    for a second reason — the map it populates holds one entry per distinct key read and nothing evicts,
    so on a bucket with many objects it is unbounded growth for a report no mount displays.

  • The small-object rule compares prices instead of one size. HandleStandardTierOverhead tested
    objectSize < 128 KiB with no reference to the configured tier, so it moved objects to STANDARD from
    tiers that publish no billing minimum at all — including DEEP_ARCHIVE, at ~23× the storage rate —
    and from the three that do at sizes where they are still much cheaper. Being under a floor does not
    make STANDARD cheaper: the crossover is at minBillable × rateTier / rateStandard, which at list
    prices is 69.6 KiB for STANDARD_IA, 55.7 KiB for ONEZONE_IA and 22.3 KiB for GLACIER_IR.
    A 32 KiB object billed as 128 KiB of GLACIER_IR costs about a third of 32 KiB on STANDARD. Three
    conditions are now required — the tier publishes a minimum, the object is under it, and STANDARD is
    cheaper for this object at the prices this deployment pays, discounts and CustomPricing included.

    This also keeps GLACIER and DEEP_ARCHIVE out on a second ground that is not about money: their
    objects cannot be read without a restore, so diverting one to STANDARD would change what a read of
    that object does, not only what it costs. A cost heuristic must not decide retrieval semantics.

    TierValidator.GetRecommendations had the identical size-only rule and now uses the same three
    conditions at list rates. It has no mount-path caller — it is exported API — but advice nobody can
    act on wrongly is still advice someone will act on.

  • The billing-minimum warning names the tier the object is actually stored on. ValidateWrite
    ran before the small-object diversion and described the configured tier's floor, so an operator who
    enabled small_objects_on_standard because of that warning still saw billed_size=131072 on a
    16 KiB object that was about to be stored on STANDARD and billed as 16 KiB. The diversion logs at
    Debug, so at the default level the misleading half was the only half visible. The tier decision now
    precedes validation and ValidateWriteToTier takes the effective tier. tier_constraints.min_object_size
    deliberately does not follow it: that is a floor the operator configured for this mount, and an
    internal cost optimization is not a reason to stop enforcing it.

  • A per-object storage class bypasses the CargoShip transporter. Transporter.optimizeStorageClass
    returns the transporter's own config storage class — fixed at construction from storage_tier — for
    any archive with no AccessPattern and no RetentionDays, which is every archive ObjectFS builds. It
    never reads Archive.StorageClass, the field whose comment says "Target storage class". So the
    diverted class was computed correctly and dropped at the boundary: the object stored fine, read back
    fine, and only the invoice differed. Objects whose class differs from the configured tier now take the
    direct PutObject path, joining the existing bypasses for compression and for encryption modes the
    transporter cannot express. The common case is unaffected and keeps CargoShip's throughput
    optimization. Filed upstream as scttfrdmn/cargoship#352; OptimizedTransporter, a different type
    ObjectFS does not construct, already honors the field.

    Found by asserting the storage class recorded at the S3 endpoint rather than the value passed in — the
    same technique that found the INTELLIGENT_TIERING default in v0.10.1, and the reason the seam test
    exists at all.

  • The live pricing drift test needs no AWS credentials and no longer names its own queries (#161).
    It fetched from the Pricing API by shelling out to aws pricing get-products, so it skipped for
    anyone without a configured profile — including CI. A drift check that skips is not a drift check. It
    now fetches the same public offer files the generator reads, over plain HTTPS, and compares the whole
    committed table against a fresh extraction across five regions spanning every price band AWS
    publishes. internal/cost's drift guard moves onto PricingManager.StorageRate for the same reason:
    the field it read no longer exists, and the manager is the path a caller actually takes.

  • The release security scan is a gate (#196). security-scan in release.yml already scanned
    the exact binary publish attaches, which is the right shape, but it could not fail: trivy-action's
    exit-code has no default, so findings uploaded as SARIF and the step passed. It now exits 1 on
    HIGH,CRITICAL, with ignore-unfixed: true and scanners: vuln.

    Each of those is a decision rather than a default. MEDIUM and below still upload and stay visible on
    the security tab without stopping a publish, because MEDIUM in a transitive dependency of a
    filesystem binary is generally not worth delaying a release for. ignore-unfixed because a
    vulnerability with no released fix cannot be actioned by a release — blocking on one means the
    project cannot ship until an upstream maintainer acts, which is an availability problem wearing a
    security posture. And the SARIF upload is now if: always(), so the findings that failed the step
    are the ones that reach the security tab; without it a HIGH stopped the job before the upload and
    left whoever was cutting the release with an exit code and no way to see the cause.

    It was not a gate before now for a reason worth keeping, because it is the same reason it can be one
    now: the first real run of this scan found a MODERATE advisory in the pinned aws-sdk-go-v2 (#195),
    and switching the gate on then would have blocked every release on a scan nobody had triaged. A gate
    turned on against existing findings is a broken build everyone learns to bypass. That advisory is
    fixed and the baseline is clean, which is the only state in which turning it on means anything.

    The asymmetry the issue names is resolved toward gating: govulncheck in security.yml exits
    non-zero and so has always been a hard gate on main and every PR, while the release — the artifact
    users actually download — was not gated at all. The repository-wide trivy fs scan in that file is
    deliberately still not a gate, and now says so: it reports on the source tree rather than on what
    ships, including dependencies reached only by tests, so the gates are govulncheck and the binary
    scan. It picks up the same severity floor and ignore-unfixed regardless, so a finding in one place
    means what a finding in the other means.

  • klauspost/compress v1.18.0 → v1.18.7, for GO-2026-5841, an out-of-bounds read in the s2
    package. Not reachable from this code — govulncheck reported it under "packages you import" with
    zero called vulnerabilities — but present in the module the binary is built from, which is what a
    binary scan sees and what the new release gate above would have failed on. Found while verifying the
    baseline was clean before switching that gate on, which is the check being described.

Removed

  • StorageTierInfo.CostPerGBMonth (#161). A rate on a package-level struct cannot know which
    region it is for, and this field was the mechanism by which every cost internal/storage/s3 reported
    was us-east-1's. Callers use PricingManager.StorageRate(tier) for the list rate in the manager's
    region, or GetTierPricing where discounts and overrides should apply.

    Removed rather than corrected, deliberately. Leaving the field and filling it from the configured
    region would put a region-specific number on a value shared process-wide by every manager, which is
    the same defect with an extra step. The compiler now finds the callers.

  • awsrates.Region (#161). A constant alias for awsrates.DefaultRegion, added in the same
    change that made rates region-aware and kept so that callers wanting only to label a figure would
    keep compiling. There were no such callers: a grep across every .go file in the repository found
    zero uses. awsrates is an internal package, so nothing outside the module can reference it either,
    and a deprecation notice nobody can read is not a compatibility measure. Use DefaultRegion, or pass
    a region to ForRegion.


Verify a download:

sha256sum -c objectfs-<platform>.tar.gz.sha256

Container image: ghcr.io/scttfrdmn/objectfs:0.11.0

Full changelog: https://github.com/scttfrdmn/objectfs/blob/v0.11.0/CHANGELOG.md