ObjectFS v0.10.3
Part 4 of the v0.10.0 audit: say only what the code does, and bill accurately. The audit found that
documentation, cost figures, and repository metadata each asserted things no mechanism checked, so
most of what follows is a correction paired with the gate that fails if it recurs — five of them,
now running on every PR.
This release exists because of a numbering defect worth stating plainly, since it is the second
instance of the same one. v0.10.2 was tagged when the first of this milestone's twelve issues
closed, and the remaining eleven landed after it — so the tag published under that number holds the
packaging fix and none of the work below, exactly as v0.10.1 was tagged two hours before the
module-path fix it was cut for. A tag is not a promise that can be revised: it is already in the
GitHub releases list and cached by the Go module proxy, which resolves a version to one tree
forever. So the fix is a new number rather than a moved tag, and the rule that prevents a third
instance is to cut the tag from the merge commit that closes the milestone, not from the one that
opens it.
Added
- A gate that fails when
CHANGELOG.md's version headings and its link definitions disagree. Keep a Changelog puts each release in a bracketed heading and defines the link separately at the bottom of the file — two hand edits per release with nothing connecting them, and markdown fails silently in both directions. An undefined reference renders as the literal text[0.10.2]instead of a link; a definition with no section renders as nothing. Neither breaks a build, fails a lint, or produces a visibly wrong page, so the only witness is a reader noticing a heading stopped being clickable, which is not a thing readers report. Both halves had already broken:[0.10.2]was never defined at all, and[Unreleased]still compared fromv0.10.1— so the link that answers "what is onmainbut not released" spanned two releases and 52 entries of already-released work.internal/config/changelog_test.gochecks three properties: every section has a definition and every definition has a section,[Unreleased]compares from the version constant, and each release's diff starts at the release immediately before it. The third exists because that failure is the one that renders and resolves — copying the previous definition and editing only the right-hand side produces a real GitHub diff covering more releases than the section it is attached to. All four failure modes were verified by mutation, which is also how the orphan-definition message got fixed: it printed the URL where the version belonged. This is the same defect the release itself is about, one file over — a fact restated in a second place with no mechanism to notice the two have drifted - A gate that fails when documentation names a Go symbol or a CLI flag that does not exist.
internal/config/docs_symbols_test.goextractspkg.Symbolreferences from fenced Go blocks — checked against the packages that same block imports, parsed withgo/ast— andobjectfscommand lines from shell blocks, checked against the flagscmd/objectfs/main.goactually declares. This is the mechanism #182 asked for, and the point is that it fires at authoring time: correcting the nineteen files that issue cataloged only resets the clock, since a fenced code block is a string as far as the compiler, vet, and lint are concerned. It found eleven defects on its first run, listed under Fixed below, and a companion test compares the flag list againstmain.goso the two cannot drift apart silently. The admission rule — which references get checked — was chosen by measuring three candidates rather than guessed: checking everylowercase.Uppercasein every Go block gives 93 findings of which 3 are real (s3.Clientis the AWS SDK,errors.Isis the standard library), file-scoped imports give 5 of which 3 are real, and block-scoped gives 3 of 3 with no false positives. Its one known blind spot is stated in the test rather than left to be discovered: a continuation block that uses a package imported by the block above it is not checked - A gate that fails when documentation links at a page that does not exist.
internal/config/docs_links_test.goextracts every relative markdown link from every tracked markdown file and resolves it on disk — relative paths against the linking file's directory, root-absolute paths indocs-platform/against VitePress's routing rule, where/guide/installationis served fromguide/installation.mdand/api/fromapi/index.md. This is #208's mechanism, and it is a Go test rather than the link checker in CI that issue proposed for three reasons recorded in the file: it needs no network and no new tool, so pre-commit and CI check at identical fidelity; it sits with the gates a contributor already satisfies; and an exemption can carry its reason in code, the waydocsExemptFromConfigSchemadoes. It found 45 dead links, not the 24 the issue catalogued — because #208 was written by walkingdocs/, and two whole classes live outside it: 13 links into SDKexamples/directories that have never existed, and 8 root-absolute VitePress routes. That gap is the finding, and it is the same shape asdocs_test.go'snestedSectionNames: scoping a gate to where the defects were already known is how the next cluster stays invisible. A link target is a path, not a symbol, which is why the symbol gate above cannot see it — a link written as[tuning]followed by(./perf.md)is prose to the compiler, to vet, and to lint, and stays prose after the file is renamed. A third test asserts the walk's reach rather than its findings, and it earns its place: a mutation that made the link regexp match nothing left the resolving test passing on zero links and green, and only the reach test caught it - A gate that checks
mkdocs.yml's nav against the tree, in both directions.TestMkDocsNavMatchesTheTreeasserts that every nav entry has a file and that every page underdocs/is either in the nav or exempt with a stated reason. Both directions, because that is how the defect ran: 47 of 50 entries pointed at no file, and 14 of the 17 pages in the tree were missing from the nav. Checking only that entries resolve would have left the orphans, which is the half a reader loses — a page absent from the nav is a page nobody finds. A nav entry is a link target with a different syntax, and that syntax is why it went unchecked: the link gate walks markdown, andnav:is YAML. It is a line scan rather than a YAML parse for a stated reason —mkdocs.ymlcarries!!python/name:tags for the emoji and superfences extensions, so decoding it needs a custom resolver or unsafe mode, and the nav is a flat list of- Title: path.mdlines that needs neither docs/features/compression.md— what transparent compression costs, and when it saves nothing. The question it answers is the one #186 was filed for: project-level compression saves bandwidth and end-to-end latency, so what else does it buy? Less than you would expect. It names four costs, each measured rather than asserted: a compressed object is not readable by anything but ObjectFS (aws s3 cpand boto3 both write the raw zstd frame to disk with a successful exit status — no error to notice); a 4 KiB read of a compressed object transfers the whole stored object, which is 1,836× / 7,344× / 29,380× amplification at 16/64/256 MiB; enabling compression turns off parallel range reads for every object in the bucket, compressed or not; and on the three tiers with a 128 KB billable floor, compressing a 100 KB object to 40 KB changes the invoice by zero. Byte counts are presented as the result and wall-clock only as an aside, for a reason stated on the page — bytes are a property of the design, latency is a property of the day, and the audit's 15.6×/43×/216.5× and this page's 3.0×/5.0×/12.3× are the same defect measured on different days. Every figure is either linked to the AWS page that publishes it or carries its bucket, region, date, and payload, which isdocs-platform/index.md's standard after its hardcoded chart was removed. Two of the numbers #186 itself specified are wrong, and the page states what AWS publishes instead: AWS applies no minimum billable object size toGLACIER,DEEP_ARCHIVE, orINTELLIGENT_TIERING. The archive classes' 40 KB is metadata added per object (32 KB at the archive rate, 8 KB at Standard), which points the opposite way from a floor — compression does reduce the bill there, it just cannot touch the surcharge, which is about 23× the payload for a 10 KB object onDEEP_ARCHIVE. Writing the page found three defects, filed as #228, #229, and #230- A gate that fails when
.github/labels.ymland the repository's labels disagree — in both directions.internal/config/labels_test.gois the fifth mechanical gate, and it exists because the file is a hand-maintained description of state held on GitHub and nothing compared the two, so they had drifted by nine labels. Both directions, because only one is intuitive: all nine existed on GitHub and were absent from the file, none the other way, so a sync that creates labels from the file is green on every one of them — it has nothing to create. That is the failure mode #190's own acceptance criteria name, and the test for the gate is the one they specify: create a label on GitHub without touching the file and confirm the job notices. Verified by doing exactly that, with a throwawayzz-drift-probe. Colors and descriptions are compared too, not just names — a label the file describes differently from the label that exists is drift with a longer fuse, because the name still filters correctly and nothing looks wrong. #190 proposed apaths:-filtered sync job that runs whenlabels.ymlchanges; measurement is why this one runs unconditionally instead. Every drift this repository has had originated on GitHub — two labels created by hand in the web UI, one invented bygh issue create --label, one created by Dependabot, six defaults never deleted — and none of those events touches the file, so a job keyed on the file changing would have fired on zero of the nine. Confirmed withgit log -Sagainst the exact- name:form of each rather than assumed. A companion offline test holds.github/dependabot.yml'slabels:blocks to the file, which is the seam that left 46 Dependabot PRs unmerged:automergewas named there, defined nowhere, and Dependabot drops a label it cannot find without reporting it internal/awsrates— one table of AWS S3 list prices, with a test that checks it against the live AWS Pricing API. Every rate in it was read from that API rather than from a pricing page, andAWS_PROFILE=aws go test -tags=integration ./internal/awsrates/re-reads 23 of them and fails on any difference, so a price change is something the suite reports rather than something a report quietly gets wrong. Two things are in the type rather than at the call sites, because both are where the errors below came from: per-request fields hold the cost of one call (AWS publishes per 1,000 or per 10,000), andGBFromBytesdivides by 10⁹ (AWS bills decimal GB). The rates are us-east-1, first volume band, Standard retrieval speed on the Glacier classes — stated in the package doc, because a rate table that does not say which band it holds invites the comparison against a different one
Fixed
-
make -C sdks/c cleandeleted a tracked file. The clean target removedlibobjectfs.halong with the shared library, but only the library is gitignored — the cgo-generated header is checked in, becausesdks/c/README.mdlists it as the reference copy of the declarations for a reader who has not built anything. So building and then cleaning leftgit statusreporting a deletion the contributor did not make. The committed header was verified byte-identical to a fresh generation before deciding which of the two to keep, since "checked-in artifact" and "stale checked-in artifact" call for opposite fixes. The same Makefile also still exportedGOPRIVATEandGONOSUMDBforgithub.com/scttfrdmn/*, the last copy of a setting removed fromCLAUDE.mdas untrue — all three repositories are public — and the C SDK builds and passes its 15 tests without it -
Configuring compression turned off parallel range reads for every object in the bucket, compressed or not. The gate selecting the fan-out asked
b.compressor.Enabled(), which reports the local write configuration and says nothing about the object being read — so a mount with compression enabled read large objects serially even when they had never been compressed, were belowmin_size, had not compressed usefully, or had been written by another tool entirely. Declining the fan-out is right for a compressed object, since a zstd or gzip frame must be decoded from its start and there is no set of independent ranges to assemble; the defect was the scope. The compounding part is who paid: most research data does not compress, so the objects that gained nothing from compression were the same ones losing parallel reads because of it, and v0.10.0's headline feature was off for the whole bucket on any mount that enabled compression. This is audit finding C4 one line above C4's own fix, and it survived for the reason C4 did not — C4 moved bytes that did not need moving, which a byte-count assertion catches, while this merely declined an optimization: nothing failed, nothing was logged. The decision is now the object's. WhereGetObjectalready needs aHEADfor the chunk arithmetic the encoding comes free with it; otherwise the fan-out is attempted and abandoned when a chunk's response carries aContent-Encoding, which keeps the cost on compressed objects — which already pay a whole-object fetch — rather than adding aHEADto every large read -
A compressed object read past the end of its stored body reported
DATA_CORRUPTIONinstead of falling back. The stored body of a compressed object is a fraction of the size the caller is told, so a read at a high offset is a range no chunk can satisfy — every chunk gets a refusal and none ever sees a response header to learn the encoding from. The recorded finding for an unsatisfiable range is "the object shrank mid-read", which is the right diagnosis when the object is not encoded and the wrong one here. That ambiguity is now resolved with oneHEAD, on a path that has already failed and only for the reads that hit it. Abandoning a fan-out also has to leave no mark on health: several chunks fail from one root cause,s3-readsdegrades at a few consecutive errors, and a degraded component refuses reads at the top ofGetObject— so one compressed object could have taken unrelated, perfectly readable objects offline.TestFanOutFallbackLeavesNoHealthErrorsassertsConsecutiveErrorsdirectly rather than checking that a later read still succeeds, becauseRecordSuccessdecrements the counter and the successful whole-object re-read that follows every fallback pushes it back down before the threshold — the weaker version passes even with the 416 misclassified as a service failure, which was checked by making that mutation -
Three of the eight storage classes carried a minimum billable object size AWS does not publish, and two of the three numbers were real AWS figures used for the opposite purpose.
StorageTierInfo.MinObjectSizeheld 40 KB forGLACIERandDEEP_ARCHIVEand 128 KB forINTELLIGENT_TIERING; AWS's storage class table lists min billable object size as NA for the two archive classes and None for Intelligent-Tiering. The archive classes' 40 KB is per-object metadata AWS bills in addition to the object, and Intelligent-Tiering's 128 KB is the size below which an object is not monitored, not auto-tiered, and not charged the automation fee. A minimum and an overhead are arithmetically opposite and so cannot share a field: a minimum replaces a smaller object's size, an overhead adds to it, and under a floor compressing a 30 KB object to 10 KB saves nothing while under an overhead it saves every byte it removes. So the direction of every small-object recommendation was wrong on the two cheapest tiers, andValidateWrite— which refuses writes below the minimum, itself a policy S3 does not have (#154) — was rejecting writes on the strength of numbers that were not minimums. The two real figures now have fields named for what they are,PerObjectOverheadBytesandMonitoringEligibilityBytes, and the archive classes warn about the surcharge at write time instead of refusing, because packing small files into one archive is a remedy available before the write and not after. What kept this in place was that each wrong value carried a confident comment stating its reason — "40 KB minimum", "128 KB minimum for optimization" — and a number with a stated reason reads as a number somebody checked -
The archive classes' 40 KB is billed at two rates, and pricing it at one understates the smaller portion 23-fold. 32 KB is charged at the archive class's own rate for the index Glacier maintains; 8 KB at the S3 Standard rate for the name and metadata S3 keeps so the object stays listable. Standard is $0.023/GB-month against Deep Archive's $0.00099, so a caller that sums the 40 KB and prices it once at the archive rate gets the cheap answer for the expensive part — on a 10 KB
DEEP_ARCHIVEobject that portion is 82% of the true total.ArchiveOverheadreturns the split, andcalculateObjectCostprices each part at its own rate -
calculateObjectCostwas 7.4% low on every figure it produced, and the helper written to prevent exactly that had no callers. It divided bytes by 2³⁰ to get GB in three places while AWS bills GB-months in decimal GB, andawsrates.GBFromBytes, added for this reason, was used nowhere outside its own package. The test could not see it: it passed1024*1024*1024, called it "1GB", and asserted1.0 * CostPerGBMonth— an expectation that holds under both the right divisor and the wrong one, because the test made the same choice the code did. A test that recomputes the implementation's formula agrees with it by construction. The seven subtests that replaced it assert hand-computed dollar literals, and their failure messages name the wrong-answer signatures, so$0.023for a GiB reads as "something is dividing by 2³⁰ again" rather than as an unexplained mismatch.TestTierSizeThresholdsMatchWhatAWSPublishespins all eight classes' thresholds with the AWS source URL in each failure message, in the shape ofinternal/awsrates/rates_aws_test.go— the rates can be re-read from the live Pricing API, but these thresholds are not published there, so a citation naming the page to open is the substitute -
.github/scripts/sync-labels.shreported success while syncing nothing, for its entire existence. It parsedlabels.ymlwith a bash regex requiring- name: "..."— double quotes — and the file has used single quotes or bare scalars since it was written, so the loop matched 0 of 78 entries and the script then printed✅ Label sync complete!and exited 0. #190 concluded from that symptom thatlabels.yml"is applied by nothing"; what was true is worse, and it is the reason the replacement is careful — the file was applied by something that reported success without doing anything, which is indistinguishable from a working sync in every log it produced. The fix is not a better regex: all three YAML scalar forms are legal, all three are present in this file, and a pattern written against one is blind to the other two. It now parses, reports drift by default and changes nothing without--apply, refuses to sync at all if the parse yields implausibly few labels, and edits labels that already exist rather than skipping them, since color and description drift is drift a create-only sync can never fix. Extras — on GitHub, absent from the file — are named with the command to remove each and are never deleted, because deleting a label removes it from every issue that carries it and that is not a thing to do as a side effect. The header oflabels.ymlalso told the reader to rungh label sync, which is not a gh subcommand at all -
Nine labels existed on GitHub and were absent from
.github/labels.yml.area: sdkandarea: ci-cdwere created by hand in the web UI —area: ci-cdarriving with a null description and the default grey, which is whatgh issue create --labelproduces when it invents a label it cannot find rather than failing.javawas created by Dependabot itself, on the first maven PR aftersdks/javagained an ecosystem entry, which is to say the drift #190 documented grew by one while that issue sat open. All three are now in the file with their provenance recorded, and the repository's 81 labels now match it exactly, descriptions and colors included — 78 of them had never been applied from the file at all, because of the sync script above -
CLAUDE.mddescribed a GitHub repository that does not exist in four places. It hand-transcribed the label taxonomy as two tables holding 8 type, 4 priority, and 12area:labels against the 22area:labels that exist — so the document a contributor consults to pick a label was missing ten of the choices, and would go stale again on the next label added. It listed six milestones, five of which are closed, and omitted every open one. Its project-board link used/orgs/scttfrdmn/projects, andscttfrdmnis a user account, so the URL 404s. And its "Private module — set these forgo get" block withGOPRIVATE/GONOSUMDBdescribes a state that has not been true for some time: all three repositories are public, verified by fetchingobjectfs@v0.10.2with both variables explicitly empty. Each is now a pointer to the authority rather than a copy of it —labels.ymlfor labels, the milestones URL for milestones — because a transcribed list is a claim with no way to be told it is stale, which is the same defect the four documentation gates were built to catch in prose -
mkdocs.yml's navigation described a documentation site that was never written. Fifty entries, three of which resolved: the other 47 named pages undergetting-started/,user-guides/,personas/,features/,operations/,architecture/,development/, andapi/that do not exist, plusROADMAP.md, which is at the repository root and therefore outsidedocs_dir.mkdocs buildfails on an entry with no file, so this configuration could never have produced a site — and nothing builds it, which is why it survived: mkdocs is not installed in this environment, no workflow runs it, and Pages is off on the repository. The nav now lists the pages that exist, grouped as the tree groups them, with the 47 recorded in a comment rather than recreated as stubs -
Fourteen of the seventeen pages under
docs/were absent from the navigation, including the four most substantial —architecture/overview.md,features/read-ahead.md,features/multipart-uploads.md, ands3-acceleration.md. So the same file both described pages nobody had written and omitted almost all of the ones somebody had. All fourteen are now in the nav;docs/README.mdis exempt with its reason, being build instructions for a contributor rather than a page about ObjectFS -
Six directories under
docs/held a.gitkeepand nothing else —api,development,getting-started,operations,personas,user-guides. Removed. An empty directory is worse here than no directory: three of the six were targets of the dead links above, and the directory existing is what made those links look plausible to whoever wrote them. Two prose pointers into them survived the link sweep because prose is not a link — one inOBJECTFS.mdand one indocs/features/multipart-uploads.md, both written during that same sweep, which is a fair illustration of how the empty directory misleads -
Both SDK READMEs pointed at seven example programs apiece, and neither
examples/directory has ever existed. Thirteen dead links, in the section a reader goes to after deciding they want to use the SDK — the point of maximum invested attention. The tempting repair is thirteen stub files, which turns the gate green and teaches nothing; #208 names it ("the docs equivalent of theecho \"Would update the Homebrew formula...\"job that v0.10.1 deleted"), so a third test now fails if anexamples/directory appears holding files too small to be working programs. Both sections now point at the inline examples above them, which do exist and do run. The Python README's monitoring pointer went tointernal/metrics/doc.goafter the obvious target turned out to be an empty directory —docs/operations/is one of six directories indocs/with no files in them -
Eight links in
docs-platform/were VitePress routes with no page behind them:/guide/installation,/guide/troubleshooting,/guide/configuration,/guide/performance,/api/, and three more. These are invisible to a walk ofdocs/and to any checker that treats/guide/installationas a filesystem path, which is why they outlived #208's audit. Resolved the two ways that issue permits — point at the page that covers the topic, or delete the link — and in one case the deletion is the finding: there is no installation guide because installation is two commands, so it belongs in the Quick Start rather than in a page of its own -
Twenty-four relative links in
docs/resolved to files that were never written, and four of them named the same one.performance-tuning.mdwas linked from four pages — read-ahead, multipart uploads, memory monitoring, and S3 acceleration — which is the pattern worth noting: four authors each assumed the page existed because the others linked it. If it is ever written it has to cite measurements, per the rule added toCONTRIBUTING.md, and that is likely why it never was.ml-training.mdwas the most misleading of the set, because there is no model to train: thePredictorinterfaceinternal/cacheaccepts is never set on the mount path.docs/index.mdlost the whole audience section — five persona links into an empty directory — replaced with the persona names as plain text, since thepersona:labels do exist on GitHub and the intent was real -
docs/features/read-ahead.mddocumented the way to read predictive-cache statistics, and there is no way to read them. The page calledcache.GetPredictiveCache(), which exists under no name. ThePredictiveCachea mount builds is wrapped insideMultiLevelCache.initializeLevelsand stored as an opaquetypes.Cachein a level;types.Cacheis six methods about bytes,GetLevelStats("L1")returns hits/misses/size, and no exported accessor reaches past either. So prediction accuracy, prefetch efficiency, and cache-hit improvement are computed on every read of every mount and then discarded at unmount. The page now says that, and shows the one way those numbers can be observed today — on aPredictiveCachethe caller constructs, which watches the caller's own accesses and nothing the filesystem does. Filed as #223, where the recommendation is to export them through the Prometheus surface the rest of the cache telemetry already uses -
The playground's Go example imported a package that does not exist and called an API that was never written.
github.com/scttfrdmn/objectfs/pkg/clientis not in the tree, and neither are theclient.Mount,GetHealth,ListObjects, andUnmountcalls beneath it;config.Configisconfig.Configuration. Replaced with the real embedding API —adapter.New,Start,Stop— which was compiled to check it before publishing, along with the caveat that makes it honest: these types are underinternal/, so the example builds inside this module and nowhere else, and running the binary is currently the only supported way to use ObjectFS from another program -
objectfs config validate,config diff, andconfig generatewere documented as runnable, and there are no subcommands.OBJECTFS.md's configuration-validation section offered four commands of which one exists; each of the other three exits 1 with an argument error. They are now shown struck through with what each would have done, since a config-diff and a config-generate remain reasonable things to want, and the one real command —--dry-run, which loads a file, runs every validation rule, and never touches the mount point — is marked as the one that works.--profile high_latencywas doubly wrong: there is no profile generator and no profile mechanism of any kind -
docs-platform/index.md's Quick Start opened withcurl -sSL https://get.objectfs.io | shfollowed byobjectfs mount— an install script for a domain this project does not serve, then a subcommand that does not exist. It now builds from source and mounts with two positional arguments, and notes that the mount runs in the foreground, which the original did not and which is the next thing a reader would have been confused by -
OBJECTFS.md's table of contents listed eight sections that were never written, and omitted the one that exists. Entries 8 through 15 — Product Family & Roadmap, Monitoring & Observability, Security & Compliance, Operations & Maintenance, Development Guide, API Documentation, Troubleshooting & Support, Appendices — have no corresponding heading at any level; the document ends at Advanced Features, which the list did not mention. So half the contents of a 1,900-line document were links to nothing and its last section was unreachable from the top. The list now matches the headings, records what the eight were, and points at the documents that do cover that ground -
CONTRIBUTING.md's eight table-of-contents links all resolved to nothing, because every heading they targeted begins with an emoji:## 🤝 Code of Conductanchors as#-code-of-conduct, not#code-of-conduct. Both files' broken links were found by markdownlint's MD051, which had been failing on them for as long as the rule has been enabled — the hook only reports files a commit touches, so a defect in a file nobody edits is a defect nobody sees -
pip install objectfsinstalled an unrelated project. The Python SDK's README documented threepip install objectfs...commands; the SDK has never been published — no workflow here publishes either SDK — and the name is taken on PyPI by a "Simple Python VFS module" from 2015 by a different author. So the command did not fail, it succeeded with somebody else's code, which is the worse of the two outcomes and indistinguishable from success until an import fails.@objectfs/sdkon npm at least 404s. Both READMEs now install from this repository and say why -
The Python and JavaScript SDKs declared themselves MIT, and this project is Apache 2.0.
setup.py's classifier andpackage.json'slicensefield, which are exactly the places a licence scanner or a dependency review looks — the Java SDK'spom.xmlhad it right, so the three shipped manifests disagreed with each other and two disagreed withLICENSE. Both now say Apache-2.0. Theteam@objectfs.ioauthor address went with them: it is not a deliverable mailbox -
docs-platform/guide/index.mdwas the last document claiming POSIX compliance, and checking the rest of the page found more: hot configuration reloading (SIGHUPis registered alongsideSIGINT/SIGTERMand treated as shutdown, so sending it unmounts the filesystem), "Authentication and authorization with RBAC" (there is no auth code — zero hits forRBAC,Authorize, orAuthenticateoutside tests), load balancing and distributed clusters as shipping features (nothing outsideinternal/distributedimports it), S3 lifecycle management (no lifecycle API call exists anywhere in the tree), Kubernetes persistent volumes (no CSI driver, no chart, no manifest), and "Compliance and governance" (no audit log). Its architecture diagram drew the distributed coordinator as a solid component and omitted both layers of the v0.10.1 refactor; it now showsinternal/fuse→internal/vfsand draws the coordinator dashed, because that is what it is -
Dead-domain links removed from six files: a community forum at
community.objectfs.io, a documentation site atdocs.objectfs.io, and commercial support atmailto:support@objectfs.io— none resolves, so a reader with a problem was sent to three dead ends before reaching the issue tracker.mkdocs.yml'ssite_urlpointed atobjectfs.io, a parked registrar page: no site is published from either documentation tree and no Pages workflow exists, so it named a domain this project does not serve in every canonical link and sitemap entry a build would emit. All now point at the repository, which is where the documentation is -
CONTRIBUTING.mdtold contributors to use LocalStack. This project does not use LocalStack and never has;CLAUDE.md,DEVELOPMENT.md, and the CI workflow all say to use real AWS or the in-process substrate emulator. The section now namesinternal/testawsas the default choice, real AWS behind-tags=integrationfor what an emulator cannot answer, and a hand-written mock as the last resort — with the reason, which is that a mock sits on the far side of a seam and agrees with its caller by construction, and that is how 32,680 lines of tests missed roughly 45 defects. Its "Running Tests" block also omitted-racefrom every command; it now shows what CI runs -
The supported-operations table now names the errno for every unimplemented operation, and a test holds it to that. Six rows said only "not implemented", which tells a user nothing about what their tool will print or branch on. The errnos are not ObjectFS's to choose — with no method on
DirectoryNode, the answer is go-fuse's default for the absent interface, and those defaults are neither uniform nor guessable:rename,symlink,link,mknod, andfallocategiveENOTSUP;getxattrandremovexattrgiveENOATTR, which isENODATAon Linux;listxattrsucceeds with an empty list; andunlinkandrmdirdefault to success, which is why this package implements them only to refuse.mvhad in fact been documented asENOSYSon the strength of a reasonable guess, and it isENOTSUP— a difference that matters, becauseENOSYSmeans "this filesystem will never do this" and a caller may stop asking.internal/fuse/unimplemented_test.gonow drives the realfuse.RawFileSystem(no mount, no macFUSE, no privileges) and asserts each one, so a go-fuse upgrade that changes a default is something the suite reports rather than something the README quietly gets wrong -
The locking row said "not implemented", and locks are not refused — they are host-local. The mount does not set go-fuse's
EnableLocks, so the kernel never negotiatesCAP_POSIX_LOCKS/CAP_FLOCK_LOCKSand never asks ObjectFS to arbitrate; it tracks locks itself, on the mounting host.flocktherefore succeeds and means nothing to any other mount of the same bucket, which is a worse failure than a refusal and a different one than the README described: two hosts will both believe they hold the same exclusive lock. Asserted alongside the errnos, in both directions — settingEnableLockswithout implementingGetlk/Setlk/Setlkwwould flip every locking caller toENOTSUP, SQLite included -
Every write was costed at a tenth of its price on the default configuration.
internal/storage/s3stored the Standard PUT rate as0.0005, which is what AWS charges per 1,000 requests, in a field the code then divided by 1,000 again — so a PUT was reported at $0.0000005 against a real $0.000005.internal/costhad the same rate right, which is the more instructive half: the two packages disagreed by 10×, so what an operation cost depended on which package a caller reached for, and neither was flagged by anything. Verified against the live Pricing API, which also turned up two more the issue had not recorded: Glacier Instant Retrieval PUT was 4× low and its GET 5× low -
Storage costs were 7.4% low, everywhere.
internal/costconverted bytes to GB by dividing by 2³⁰, with a comment asserting the binary reading was correct. S3 quotes GB-months in decimal GB, so the correct divisor is 10⁹ and the ratio between them is exactly 1.0737. The comment is why it survived: it made a wrong unit look considered rather than mistaken, so a reader checking the line found a deliberate-looking choice and moved on. The tests could not catch it either — they passed1024*1024*1024bytes, called it "one GB", and asserted the per-GB rate came back, which is an expectation that holds under both the right divisor and the wrong one. The new tests state hand-computed dollar figures as literals instead -
Glacier Flexible Retrieval's retrieval rate was
0.02/GBwith the comment "Variable based on retrieval speed" — which is not a rate, it is a record that nobody had established which of the three retrieval speeds it was. AWS charges $0.01/GB for Standard retrieval; the table now says so, and the package doc names the speed each Glacier figure assumes -
internal/cost/pricing_drift_test.gocomparedDefaultPricesagainst its own hand-written copy of the rates, on the stated grounds that importinginternal/storage/s3would create a cycle. There is no cycle —go list -depsconfirms s3 does not import cost — so the literal was a third copy of the rate card, introduced by the test whose purpose was to catch there being more than one. It also had no entry forREDUCED_REDUNDANCY, so that tier went unchecked by the drift test that existed to check tiers. It now compares both tables directly againstinternal/awsrates, for every class the config loader accepts
Changed
make testnow runs the race detector, which it did not, whileCLAUDE.mdandCONTRIBUTING.mdboth said every test in this project runs with it. The local gate was therefore weaker than the CI gate it stood in for — and this repository had sixteen concurrency bugs filed after a document declared it race-free, most of them found by the detector.test-raceis kept as an alias so existing habits and scripts keep workingCONTRIBUTING.mdstates the rule for performance claims that no test can enforce. A throughput, latency, or speedup figure in documentation must cite the benchmark that produced it by file and function, the parameters it ran with, and a copy-pasteable command; without all three, say nothing about throughput. This is the procedural half of #182's gate, and it is a rule rather than a test because a number in prose cannot be compared to a benchmark automatically. The audit's most-repeated false claim was "4.6x throughput improvement" in 21 places across 9 files, uncheckable by construction, attributed to a congestion-control implementation with no caller on any mount path — which is why it survived nine files' worth of review. The four gates that are mechanical are listed in a table alongside it, so a contributor whose PR fails one knows where it lives- The five copies of the S3 rate card are now one. Rates lived in
internal/cost/pricing.go,internal/cost/reporter.go,internal/storage/s3/tiers.go,internal/storage/s3/doc.go, andinternal/analytics/model.go; all five now read frominternal/awsrates. This is the shape fix rather than the value fix — correcting the 10× error without it would leave the arrangement that produced the error, and #209 named the consolidation as the prerequisite that makes wiring up the real Pricing API (#183) a single-site change instead of five.StorageTierInfo.CostPerGBMonthis now filled in at init and panics if a tier has no rate, because the alternative is a zero, and a cost report showing $0 reads as free storage rather than as a lookup that missed internal/storage/s3's package doc no longer quotes a per-GB price for each tier, and the storage-class summary table has lost its Cost/GB column. A rate in a doc comment has no way to be told it is stale, so the only question is when it starts lying. What stays is the part that is S3 behavior rather than S3 price — minimum billable size, minimum storage duration, retrieval latency — which changes when AWS changes the product, not when AWS changes a numberinternal/cost.Reporter.Report's doc said to "pass 0.023" for the ROI baseline, making it a sixth place the Standard rate was written down. There is now aStandardBaselinePerGBderived from the rate table, so savings-versus-Standard is measured against the same rate everything else is charged at. A baseline drifting from the live rate is the hardest kind of discrepancy to notice: cost figures stay right, savings figures go wrong, and only their difference is wrong- No document claims a throughput figure any more. "4.6x" appeared 24 times across 10 files — in the
internal/storage/s3package doc, indocs/index.md's performance table, in the ROADMAP's success criteria — attributed variously to BBR, to CargoShip, or to both. It is CargoShip's number for CargoShip's workload, restated as a property of ObjectFS; nothing here measured it and no benchmark here can produce it. The mechanism it was credited to is not even on the path:internal/network's BBR surface (NewBBRDialer,BestAvailableDialer,IsBBRAvailable) has no caller outside its own tests, and what runs is a best-effortTCP_CONGESTIONsocket option on Linux and a plainnet.Dialerelsewhere. Each site now names the mechanism, or citesbenchmarks/and the parameters a figure would need. The 0.1.0 changelog entry keeps its figures under a banner saying they were never measured, for the same reason the withdrawn 0.10.0 entry is still here: a changelog records what was published docs/index.mdgained a Not yet wired up table, and the features in it left the feature list. Cost tracking, archive access, the REST API, detailed per-file metrics, ML tier prediction, the Redis cache, and multi-node coordination all have code and documentation pages and no path from a mount that reaches them. Verified by import graph rather than by reading the code, which is what caught the two that are subtler than "no importer":internal/analyticsis imported byinternal/cache, but thePredictorfield is never set on the mount path, so the size heuristic always runs;internal/cache/redisis selected bycache.NewFromConfig, but nothing callsNewFromConfig— the adapter constructsNewMultiLevelCachedirectly. Listing them beats deleting the pages: the code exists and may be wired up, and a reader deserves to know which column a feature is indocs/ARCHITECTURE_EVOLUTION.md,docs/PLATFORM_STRATEGY.md, anddocs/CARGOSHIP_MODULARIZATION_REQUEST.mdopen with banners recording that they are 2025 design sketches and where the code diverged from them, in both directions. The multi-protocol path is unimplemented — no SMB, no NFS, nointernal/protocols. Theshared/aws-optimizationmodule was never built; ObjectFS importspkg/aws/s3andpkg/aws/configfrom CargoShip directly. CargoShip now does exportpkg/s3optimization, holding different components than the one requested, and ObjectFS imports none of it. The divergence is left visible rather than edited away — the Phase 1 layout proposedinternal/filesystem+internal/protocols/fuseand what shipped isinternal/vfs+internal/fuse, and saying so is more useful to a reader than a document that appears to have predicted itselfCLAUDE.md's architecture line still routed throughcgofuseand skipped both layers of the v0.10.1 refactor. It now readsKernel VFS → FUSE (go-fuse) → internal/fuse → internal/vfs → Adapter, which is what the code does- No document calls ObjectFS "POSIX-compliant". It appeared in
OBJECTFS.mdthree times, indocs/VISION.mdthree times, indocs/DESIGN_PRINCIPLES.mdas "✅ Full POSIX compliance", and ininternal/adapter's package doc — against roughly ten of forty VFS operations, with no rename, no links, and no xattrs. Each site now says what is true: a POSIX interface, with the README's supported-operations table as the authority.OBJECTFS.mdpreviously carried a banner noting the claim was wrong while the body went on making it three more times, which is the weaker fix — a reader who skips the banner reads the claim, so the sentences no longer make it docs/VISION.mdlisted "Compliance (HIPAA, FISMA, SOC 2)" as a medium-term objective. Removed rather than reworded: ObjectFS holds no certification under any of the three, adds no authorization layer of its own, and writes no audit log. The heading did say these were aspirations, and that is a real defence — but a procurement reviewer scanning bullets does not read headings, and the same claim was removed from the documentation platform's landing page in v0.10.1 for that reasondocs-platform/guide/getting-started.mdanddocs-platform/playground/index.mdhad their shell commands corrected againstobjectfs --help. Between them they opened withcurl -sSL https://get.objectfs.io | shand went on to useobjectfs mount,objectfs unmount,objectfs list-mounts,objectfs status,objectfs health, andobjectfs metrics --watch— none of which exist; the binary has no subcommands and takes two positional arguments. Also gone: an apt repository, a Homebrew tap, an AUR package, "Windows with WSL2" as a prerequisite, and two flags (--enable-predictive-caching,--cost-optimization) that are not parsed. These pages are in a tree that cannot build (#214), and were corrected anyway because a markdown page is read on GitHub whether or not the site renders, and a wrongcurl | shis something a reader will type. Their SDK code blocks are deliberately left alone, with the reason stated in-page
Removed
- Six of GitHub's nine default labels, which duplicated the
type:family and had never been used:bug,enhancement,documentation,duplicate,invalid, andwontfix, each shadowed bytype: bug,type: enhancement,type: documentation,resolution: duplicate,resolution: invalid, andresolution: wontfix. Confirmed at zero issues and zero PRs apiece before deleting; the seven issues carrying the bareenhancementlabel were relabelledtype: enhancementfirst, since deleting a label removes it from everything that had it.good first issue,help wanted, andquestionare kept — they collide with nothing and.github/labels.ymldeclares all three. Project board #11, "ObjectFS v0.5.0 Development", is closed for a related reason: all 14 of its items were closed issues, nothing had touched it since February, and its title named a release five versions back.CLAUDE.mdlinked it as a place to track work docs/performance-metrics.md— 618 lines describinginternal/metrics/detailed.go, with nine verified defects: a zero-argument constructor for a function taking four arguments, aNewDetailedPerformanceMetricsWithOptionsthat never existed, three getters that do not exist,OpMkdir/OpRmdir/OpStatfsagainst the realOpMkDir/OpRmDir/OpStatFS, an undeclaredCacheSourceNone, a four-argumentRecordNetworkOperation, and two behaviours documented as working that are not. It was absent from the site nav with no inbound links. Replaced by a short section in theinternal/metricspackage doc — short so that it can stay true, which a separately-maintained 618-line description of one 600-line file cannot. Two of the nine were code defects rather than documentation defects, and are recorded there and filed:P50Latency/P95Latency/P99Latencyare declared and never assigned, so anything serializing the struct publishes zeros as percentiles (which reads as a fast filesystem); andLatencyHistogramis indexed byint(latency.Milliseconds()) % 100, so 1 ms, 101 ms, and 201 ms share a bucket — a modulo, not a bucketing
Verify a download:
sha256sum -c objectfs-<platform>.tar.gz.sha256
Container image: ghcr.io/scttfrdmn/objectfs:0.10.3
Full changelog: https://github.com/scttfrdmn/objectfs/blob/v0.10.3/CHANGELOG.md