OfflineKit import-CAS capacity guard procedure
This procedure is the operator and developer response for OfflineKit’s owned volume state volume:offlinekit-import-cas. It covers the doctor measurement and the P7 pre-write pause introduced by SPRINT_20260722_025 OK-CAP, the carrier row for SPRINT_20260722_028 NPS-3.
Contract
- Measured tree:
{OfflineKit:Import:StorageRoot}/cas - Owned-state id:
volume:offlinekit-import-cas - Owned-state kind:
volume-path - Logical budget key:
Doctor:State:volume:offlinekit-import-cas:BudgetBytes - Shell-safe deployment variable:
OFFLINEKIT_IMPORT_CAS_BUDGET_BYTES - Doctor check id:
doctor.standard.state.size-budget.volume-offlinekit-import-cas - Stable write failure:
CAS_WRITE_PAUSED(HTTP 507) - Stable measurement failure:
offlinekit-import-cas-measurement-unavailable
The budget is a positive byte count. An absent budget leaves the import write path enabled unchanged and makes the doctor check report the measured size as unbudgeted, at info severity and visible. An invalid declared budget, an unmeasurable CAS, an unmeasurable carrier, arithmetic overflow, or a projected size above the budget pauses the promotion before the verified carrier is moved out of quarantine.
Do not spell the logical key directly as Doctor__State__volume__offlinekit-import-cas__BudgetBytes in Compose. Although that is the usual .NET environment-key transformation, the embedded hyphen makes it an invalid POSIX shell identifier and the image’s sh -c entrypoint drops it before dotnet starts. The shell-safe alias above is the deployment contract, and offlinekit-web maps it onto the logical key before anything reads it.
What is measured, and what is not
Only cas/ — the promoted, verified carriers this service is the custody authority for. quarantine/ and multipart-buffer/ share the same StorageRoot and are deliberately excluded: the import path deletes both on success and on failure, so folding them in would make the number jump with in-flight uploads and make a budget impossible to set. cas/ is the part that only ever grows.
Symbolic links are not followed. A CAS tree reached through a link could be counted twice or could leave the volume entirely, and either makes the number describe something other than what was provisioned.
Why the guard is on the promote path
OfflineKitImportStorage.PromoteAsync is the single write that grows the CAS: it moves a verified carrier from quarantine into cas/sha256/{xx}/{digest}. The guard runs there, and its position within that method is load-bearing:
- after the already-promoted branch, because re-promoting content the CAS already holds adds no bytes. Guarding it would refuse a no-op — an operator retrying an import that already succeeded would be told the volume is full, and reclaiming bytes would not help;
- before the move, so nothing is half-written when a promotion is refused;
- after verification, so a refused write is never a statement about the carrier’s trustworthiness.
The verified carrier is left in quarantine when a promotion is paused. A paused write that also destroyed the operator’s upload would turn a capacity problem into a data-loss one.
The measurement and the pause share one authority
Both go through OfflineKitPartsOwnedStateMeasurer.MeasureCasBytes over the same root under the same state id. This is deliberate: two implementations would let an operator read a healthy capacity check while writes were being refused, which is the confusion a capacity guard exists to remove.
Measured zero versus unmeasurable
A CAS directory that exists and is empty measures 0 bytes and reports healthy. That is a true statement.
A CAS directory that is missing throws rather than reporting 0. On a host that started this cannot be a misconfiguration — OfflineKitImportOptions.BindAndValidate creates all four storage roots at composition, so an absent cas/ means the tree went away under a running process: the volume was unmounted, remounted elsewhere, or removed. The doctor check reports that as an unhealthy Warning naming the exception type, instead of reporting 0 bytes and “healthy” for a store that is not there.
A subtree that cannot be enumerated also throws. A partial total reported as a complete one would silently under-report a store that may be over budget.
Operator response
The check reports above 80% or over budget
- Read the current size from the check evidence (
state.size.bytes) and the declared budget (state.size.budget-bytes). - Reclaim by removing CAS objects for carriers no longer needed. The CAS is content-addressed by carrier digest under
cas/sha256/{xx}/{digest}; the custody ledgerofflinekit.bundle_importsnames which digests are still referenced. Do not remove an object a ledger row still points at — the import record would then name content the estate cannot produce. - Or raise
OFFLINEKIT_IMPORT_CAS_BUDGET_BYTESif the volume genuinely has room and the budget was set too low. Raising the budget without checking the volume converts a capacity warning into a disk-full outage.
An import returns HTTP 507 CAS_WRITE_PAUSED
The verified carrier is still in quarantine and was not promoted. The response message carries the projected size, the budget, the current size and the carrier size. Reclaim as above, then re-submit the same carrier — the import is idempotent on the carrier digest.
The check reports offlinekit-import-cas-measurement-unavailable
The measured tree is gone or unreadable from the running process. Check that the volume carrying OfflineKit:Import:StorageRoot is still mounted in the container and that the process can read it. Until it is measurable the P7 guard pauses every promotion, which is the intended fail-closed behaviour: an estate that cannot see its store must not keep writing to it.
Related
docs/modules/scanner/operations/cas-capacity-guard.md— the same pattern over an S3 bucket rather than a volume path; the two are deliberately shaped alike.docs/architecture/database-ownership-matrix.md§6.3 — the owned-state row. It is a VOLUME, so it lives in the named-volumes section, not the object-store section §6.2 that earlier text pointed at.- ADR-039 P7 (write pause) and P16 (owned non-Postgres state).
