Skip to content

This is the multi-page printable view of this section. .

Return to the regular view of this page.

Design Records

Product requirements, compatibility decisions, and implementation contracts for the SILO fork.

Design records capture the reasoning behind SILO maintenance decisions: the problem being solved, the compatibility boundary, rejected alternatives, implementation requirements, and the evidence required before release.

1 - DSN-Only Database Notifications: A Compatibility Boundary for #53

This document is the product requirements and final design record for SILO issue #53. It records the accepted compatibility boundary, implementation, and verification for PostgreSQL and MySQL bucket-notification targets.

Decision

SILO will retain PostgreSQL and MySQL notification targets, but support exactly one current configuration form for each:

  • PostgreSQL requires a complete connection_string.
  • MySQL requires a complete dsn_string.

The old five-field form — host, port, username, password, and database — remains unsupported by the current KV configuration system. SILO will not re-register those keys and will not synthesize a DSN from them during legacy migration.

The legacy migration contract is deliberately narrow:

Legacy target Result
Disabled Ignore it; no target is emitted.
Enabled with a non-empty connection_string or dsn_string Migrate only the canonical connection-string key and the other registered target settings.
Enabled with only discrete connection fields Reject migration and abort server startup before the new configuration is activated, with an actionable error that names the subsystem and target but never prints a credential.

This is a configuration-boundary decision, not removal of the database-notification feature.

Status: implemented in server commit f1ba68358; release pending.
Owner: SILO server repository.
Tracking: pgsty/silo#53.
Target: the next SILO patch release after implementation and verification.

Context

SILO inherited two generations of database-notification configuration from MinIO.

The pre-KV JSON configuration could describe a database connection either as a complete string or as five fields:

host
port
username
password
database

The current KV configuration exposes only the driver-native form:

notify_postgres  -> connection_string
notify_mysql     -> dsn_string

This direction is not new. MinIO deprecated the five discrete fields in RELEASE.2020-04-10T03-34-42Z and instructed operators to move to connection_string or dsn_string. SILO’s current help tables, environment-variable documentation, and examples already present the complete string as the supported interface.

SILO is a new community fork with an explicit migration step. Its compatibility contract prioritizes the S3 and Admin APIs, current MINIO_* settings, on-disk data, and current KV configuration. It does not need to perpetuate every pre-2020 configuration spelling when a supported canonical form has existed for years.

The defect

Before the fix, the legacy migration helpers, SetNotifyPostgres and SetNotifyMySQL, wrote both forms into the new KV configuration. Even when the old target already had a complete connection string, the helpers also emitted all five discrete keys, usually with empty values.

The new parser rejects those keys because neither DefaultPostgresKVS nor DefaultMySQLKVS registers them. Key validation checks key presence, not whether the corresponding value is empty. Both legacy source forms therefore fail:

old complete string -> canonical string + five empty unknown keys -> rejected
old discrete fields -> empty canonical string + five populated unknown keys -> rejected

The failure is amplified by notification initialization. FetchEnabledTargets is fail-fast across notification subsystems: the first invalid subsystem returns an error and a nil target list. The caller logs the error and continues starting the object server, leaving healthy Webhook, Kafka, NATS, and other targets unavailable as well.

Merely returning an error from the two migration helpers does not fix that behavior. The error propagates through readConfigWithoutMigrate and initConfig, but initConfigSubsystem currently logs non-retriable configuration errors as “some features may be missing” and returns success. The server then starts without assigning globalServerConfig; notification failure is only one consequence, because region, storage class, compression, identity, and other stored settings may also be absent. The implementation must therefore carry a typed database-migration error to the startup boundary and make that error fatal. Classifying it as retriable is also wrong because the server would retry forever without any state change that could repair the configuration.

The resulting behavior is especially dangerous because object I/O still works. Operators can see a healthy S3 service while every configured event pipeline has stopped. Targets are never constructed, so delivery or later replay of events produced during the outage must not be assumed.

There is also a diagnostic-exposure issue. The unregistered password key has no sensitivity metadata and may be copied verbatim into health or diagnostic material. The registered connection_string and dsn_string keys are already treated as sensitive values.

Why the first fix was reverted

The first repair registered the five discrete keys and taught the parser to read them. That made migrated targets pass CheckValidKeys, and it appeared attractive because the target argument structures and constructors still contain code for the old fields.

It also broke the documented connection-string path.

The shared mc admin config set tokenizer discovers field boundaries by looking for registered key names. It is not fully quote-aware. Once port became a registered key, this valid input contained what looked like a second top-level field:

connection_string="host=db port=5432 dbname=events user=app"

The tokenizer split at the port= inside the quoted value, truncated connection_string, and handed the remainder to the port parser. The command then failed with invalid port.

Under the current tokenizer, registering common words such as host, port, and password creates a direct conflict between the connection-string grammar and the top-level KV grammar. The attempted registration fix was therefore reverted. Re-registering those keys is not an acceptable solution.

Product judgment

Database notification targets are a specialized but useful capability. They provide a direct database-backed namespace view or access journal without requiring an external event bus. That remains valuable for small deployments and for users already operating PostgreSQL or MySQL.

The legacy spelling of their connection parameters has much less value. A five-field model cannot represent the useful range of driver options: TLS modes and certificates, connection timeouts, application names, Unix sockets, multi-host PostgreSQL settings, MySQL driver parameters, and future driver capabilities. Supporting both forms also creates precedence, merging, redaction, and testing questions that do not exist with one canonical value.

The complete string is the better abstraction boundary: SILO owns notification semantics, while the database driver owns connection syntax.

The product decision is therefore to keep the capability and remove the compatibility illusion. An unsupported legacy target must be rejected clearly; it must not be accepted and transformed into a configuration that later disables unrelated targets.

Goals

  1. Establish connection_string and dsn_string as the only supported live configuration interfaces for database notifications.
  2. Allow a legacy JSON target that already contains the canonical string to cross the migration boundary without modification to its connection semantics.
  3. Reject enabled discrete-only legacy targets before a partial or invalid KV configuration is activated.
  4. Replace the current silent runtime failure mode of #53 — healthy targets disabled while the server appears healthy — with an explicit startup-time failure that operators must resolve before the server runs.
  5. Ensure no migration error, log line, health report, or diagnostic bundle exposes a database password.
  6. Remove the ten Postgres/MySQL exceptions from the source-level unregistered-write audit.
  7. Make the compatibility boundary and operator remediation explicit in release and migration documentation.

Non-goals

  • Supporting both DSN and discrete database fields in the current KV interface.
  • Automatically synthesizing a DSN from old discrete fields.
  • Rewriting the shared KV tokenizer.
  • Changing FetchEnabledTargets fail-fast semantics in this patch.
  • Silently skipping an enabled database target and continuing with partial notification coverage.
  • Removing PostgreSQL or MySQL notification targets.
  • Deleting the legacy struct fields needed to decode and identify unsupported input. They remain on shared target argument structs that are also used by live constructors, whose discrete-field connection-string synthesis is unreachable from current KV configuration; those fields must not become supported configuration keys.
  • Correcting ignored errors from the other eight legacy notification setters. Their pre-existing silent-skip behavior remains unchanged in this narrowly scoped database-migration patch and requires a separate audit and design decision.

Functional requirements

Current configuration

  1. notify_postgres accepts connection_string; notify_mysql accepts dsn_string.
  2. The five discrete keys remain unregistered and rejected by current configuration commands.
  3. Existing full strings must continue to support the database driver’s syntax, including parameters whose names contain host, port, user, password, or database.
  4. No new public environment variables or KV keys are introduced.
  5. The declared legacy variables MINIO_NOTIFY_POSTGRES_HOST/PORT/USERNAME/PASSWORD/DATABASE and their MySQL equivalents are not wired into current parsing and remain unsupported. They must not be documented as working alternatives to the complete-string variables.

Legacy migration

  1. SetNotifyPostgres must return without emitting a target when the legacy target is disabled.
  2. For an enabled target, SetNotifyPostgres must require a non-empty ConnectionString and write only registered Postgres keys. If both a canonical string and discrete fields are present, the canonical string wins and every discrete value is discarded.
  3. SetNotifyMySQL must apply the equivalent rule to DSN.
  4. Neither helper may emit host, port, username, password, or database.
  5. A missing canonical string must return a typed or wrapped migration error identifying the subsystem and target name.
  6. cmd/config-migrate.go must check and propagate both helper errors. Ignoring them is forbidden.
  7. No partially migrated configuration may be activated or persisted after either helper fails.
  8. Error text may name the required key and remediation, but must not include any connection-field value.
  9. The propagated typed migration error must abort server startup. It must not be downgraded to the non-fatal “some features may be missing” path in initConfigSubsystem, and it must not enter the retriable-error loop.
  10. Validation errors for a supplied canonical string follow the same startup-fatal and secrecy rules; wrapping must add target context without repeating the DSN or its components.

Recommended error shape:

notify_postgres:archive uses unsupported legacy discrete connection fields;
set connection_string before migrating to SILO

Operator remediation

An operator encountering the error must choose an explicit remediation path. This applies both before an initial switch to SILO and when upgrading a deployment that is already running SILO: legacy migration output is not persisted, so the same old JSON source can re-enter migration on every start. A deployment that currently starts with notifications silently broken can therefore fail to start after this repair until the source configuration is corrected.

  1. On a compatible intermediate MinIO release, replace the old fields with connection_string or dsn_string, verify the target, and then migrate to SILO.
  2. Disable or remove the legacy database target, migrate the server, and recreate the target with the canonical string afterward.
  3. For a fresh SILO installation, create the target directly with the canonical string; no legacy migration is involved.
  4. For an existing SILO deployment that still reads a legacy JSON file, stop on the previous working release, back up the source configuration, then convert, disable, or remove the database target before starting the fixed release. Do not delete or rewrite unrelated configuration.

Documentation must not suggest that a discrete-only target will be converted automatically.

Availability trade-off

This decision intentionally turns one unsupported configuration from a degraded startup into a hard startup failure. The immediate availability cost is real: a server that previously served objects while all notifications were silently dead may refuse to start after the repair.

That cost is accepted because an object server that appears healthy while configured event sinks are absent creates silent, potentially unrecoverable downstream data loss. SILO is a new fork with an explicit migration boundary, and the discrete form has been deprecated since 2020. A fatal, actionable precondition is preferable to an upgrade that reports success with reduced notification coverage. The release note must make this startup behavior prominent; it must not be buried as an internal migration cleanup.

Security requirements

  1. The unsupported-input error must never format the legacy argument structure or its values.
  2. Tests must use a sentinel password and assert that it is absent from returned errors and captured logs.
  3. Migrated output must contain the registered sensitive connection-string key and no standalone password key.
  4. If a diagnostic bundle was exported from an affected deployment before this repair, operators should treat the database password as potentially disclosed and rotate it.

Alternatives considered

Register and parse the discrete fields

Benefit: preserves the old source form and uses already existing argument fields.
Rejected because: registration makes common field names visible to the shared tokenizer and corrupts quoted connection strings. It also expands the supported public configuration surface after the fields were deprecated in 2020.

Synthesize a canonical string during migration

Benefit: preserves discrete-only legacy installations.
Rejected because: it creates permanent code and test ownership for an obsolete input form, including PostgreSQL quoting, MySQL DSN formatting, socket and IPv6 behavior, defaults, and future driver drift. For a new fork with an explicit migration boundary, the benefit does not justify the continuing surface.

Skip only the unsupported target

Benefit: keeps the object server and other notification targets running.
Rejected because: silently discarding a configured event sink can cause unobservable and unrecoverable event loss. A clear migration failure is safer than an apparently successful upgrade with reduced notification coverage.

Change global notification fail-fast behavior

Benefit: limits the blast radius of future invalid targets.
Rejected for this change because: it neither repairs the database target nor closes the credential-exposure path, and it changes system-wide error semantics. It may be evaluated independently with its own operational contract.

Remove database notification targets

Benefit: removes the complete database-specific maintenance surface.
Rejected because: the targets remain useful and self-contained. The defect belongs to an obsolete configuration form, not to the notification capability itself.

Implementation scope

The server change should remain narrow:

  1. Update internal/config/notify/legacy.go so the two database setters emit only canonical registered keys and reject enabled targets without a canonical string.
  2. Update cmd/config-migrate.go to propagate the two database-helper errors with subsystem and target context.
  3. Define a typed database-migration error and update cmd/server-main.go so initConfigSubsystem returns it as fatal instead of logging and ignoring it. It must remain non-retriable.
  4. Leave ignored errors from the other eight legacy notification setters unchanged in this patch; record them for a separate audit rather than expanding #53 implicitly.
  5. Remove all ten Postgres/MySQL entries from knownUnregisteredWrites; the ratchet should become empty unless another independently justified legacy exception exists.
  6. Add focused migration, startup, validation, secrecy, and coexistence tests.
  7. Update database-notification and migration documentation in silo.pgsty.com.

The patch must not register the old keys, change the generic tokenizer, or refactor unrelated notification targets.

Acceptance criteria

The implementation is complete only when all of the following are demonstrated:

  1. A legacy PostgreSQL target with a complete connection string migrates, passes CheckValidKeys, and is returned by GetNotifyPostgres unchanged.

  2. A legacy MySQL target with a complete DSN does the equivalent.

  3. Discrete-only enabled targets for both databases fail before target initialization with an actionable error containing the subsystem and target name, and server startup aborts.

  4. Missing-string and malformed-string errors contain none of the sentinel host, username, password, database, or DSN values.

  5. Disabled discrete legacy targets do not create configuration entries and do not block migration.

  6. Migrated KVS output contains none of the ten discrete keys, including empty ones.

  7. When a legacy target contains both a canonical string and conflicting discrete values, only the canonical string is migrated and no discrete sentinel appears in any output KVS value.

  8. A SetKVS regression test using the real DefaultPostgresKVS and DefaultMySQLKVS key sets accepts a quoted connection string containing port=, host=, or password=.

  9. A configuration containing healthy Webhook, Kafka, or NATS targets cannot reach FetchEnabledTargets with an invalid migrated database target because readConfigWithoutMigrate fails without yielding, persisting, or activating a partial configuration, and startup aborts on that typed error.

  10. initConfigSubsystem returns the typed migration error; it neither logs-and-continues nor enters the retriable loop.

  11. knownUnregisteredWrites no longer contains Postgres or MySQL exceptions.

  12. The following verification passes:

    go test ./internal/config/notify ./internal/config ./internal/event/target -count=1
    go test -v ./cmd -run 'Test(ReadConfigWithoutMigrate|InitConfigSubsystem)' -count=1
    git diff --check

    The verbose cmd output must show that tests with both prefixes actually ran; a zero-match warning is a failed acceptance check. The normal server CI suite must also pass. In the documentation checkout, run make check.

Implementation result

Server commit f1ba68358 implements the accepted design without expanding the public configuration surface:

  • the two legacy database setters emit only connection_string or dsn_string plus registered target settings;
  • disabled targets remain ignored, while enabled targets without a canonical string return a value-free LegacyDatabaseTargetError;
  • only the two database migration errors are newly propagated;
  • the typed error is non-retriable, escapes initConfigSubsystem, and is classified as fatal by serverMain before logger.FatalIf exits the process;
  • the ten Postgres/MySQL exceptions were removed from knownUnregisteredWrites;
  • focused tests cover complete-string round trips, canonical precedence, discarded discrete values, secrecy, failed-migration atomicity, startup classification, and the real tokenizer key sets.

The final local Claude Code review used Claude Fable 5 at max effort and returned GO with high confidence and no blocking findings. Verification included the focused package set, race tests, go vet ./cmd, and the complete go test ./cmd -count=1 suite. The review authorized only the six-file server commit; publication remains a separate gate.

Cross-repository review found no implementation changes are required in pgsty/mc, pgsty/silo-pkg, or pgsty/silo-console: the client forwards configuration text, the package repository owns no notification schema, and Console already serializes its form into the canonical connection_string or dsn_string. The public reference and compatibility documentation is updated with this record.

Release and compatibility statement

The release note must describe this as an enforced compatibility boundary:

SILO database notification targets require connection_string for PostgreSQL and dsn_string for MySQL. The pre-2020 discrete host/port/username/password/database form is not migrated. Convert or recreate such targets before switching the deployment to SILO.

Deployments already running SILO with an old-format source configuration are equally affected: after this release the server will not start until each enabled legacy database target is converted, disabled, or removed.

The issue should close only after the repair is present in a published server tag. A merged patch, a local site build, and a published release are separate completion gates.

Review record

Claude Fable 5 reviewed the first draft at xhigh effort on 2026-08-23 and returned approve with required changes. The required calibration was incorporated: startup-fatal propagation now extends through initConfigSubsystem; already-running SILO deployments are covered; the availability trade-off is explicit; canonical-string precedence, dead legacy environment variables, other ignored helper errors, and executable tests are specified.

The same model then completed a final source-backed verification pass. Final verdict: approve, with no blocking findings. It confirmed that the English and Chinese records are aligned, the requirements are implementable against the current server tree, and the acceptance criteria cover the startup, migration, parser-regression, and secrecy boundaries.

After implementation, a separate local Claude Code review using Claude Fable 5 at max effort traced the path through ExitFunc(1), inspected driver error behavior, ran the focused, race, vet, and full cmd suites, and returned GO with high confidence and no blocking findings.

2 - Preview Text, Never Execute It: SILO Console Text Preview PRD

Status: accepted design; implementation pending · Owner: pgsty/silo-console · Tracking: pgsty/silo#17 · Review: consensus of product, security, and frontend architecture reviews

SILO Console can preview images, PDFs, audio, and video, but not the small logs, text files, JSON documents, and XML documents that operators inspect every day. A correctly stored Content-Type does not help: these objects are classified as unsupported before the preview renderer is selected.

Restoring the old browser-native behavior would be easy. It would also be the wrong fix. An object in storage is controlled by the user who uploaded it. Loading that object as a same-origin HTML or XML document would turn a convenience feature into an execution boundary.

The accepted design therefore makes a stronger promise:

SILO previews eligible objects as bounded UTF-8 text. It never asks the browser to interpret their markup, MIME type, or file contents as a document.

This record fixes the product boundary, the resource limit, the security invariants, the implementation shape, and the evidence required before the feature can ship.

Decision

The first release will add a dedicated text preview type and a PreviewText component.

The contract is:

  1. Preserve every existing image, PDF, audio, and video classification.
  2. Only when the existing classifier returns none, consider a text fallback.
  3. Admit the four target extensions or four exact passive text MIME types.
  4. Fetch bytes through the ordinary authenticated download path, without preview=true.
  5. Enforce a hard application read limit of 1 MiB.
  6. Decode only strict UTF-8 and reject binary-looking content.
  7. Render one React text node inside a scrollable <pre>.
  8. Never use an iframe, HTML parser, XML parser, or HTML injection API.
  9. Show the complete object or no object; do not show a truncated JSON or XML document.
  10. Keep download available for files that are too large, invalidly encoded, or otherwise unavailable.

No Console API or S3 API change is required. The backend inline MIME allowlist is not expanded.

Current behavior

The defect is present in SILO Console v2.1.1, the version currently pinned by SILO when this design was written.

The frontend preview union contains only:

image | pdf | audio | video | none

Its extension table contains media formats, but not .log, .txt, .json, or .xml. Its MIME classifier likewise ignores text/plain, application/json, application/xml, and text/xml.

Runtime verification produced this split:

Object Frontend result Console download response
.log / text/plain none inline, SAMEORIGIN
.txt / text/plain none inline, SAMEORIGIN
.json / application/json “Preview unavailable” inline, SAMEORIGIN
.xml / application/xml none attachment, DENY

The object-detail action also uses the wrong conjunction when deciding whether Preview should be disabled. An authorized user can click Preview for an unsupported object and receive only the unavailable message; in other combinations, the UI can offer an action before the server rejects it.

The preview component still contains a generic same-origin iframe fallback. It is unreachable under the current type union, so the current defect is not an exploitable text-preview XSS. The dead branch is nevertheless hazardous: adding text to the union and letting it fall through would reactivate precisely the document-loading behavior this design rejects.

Root cause

This is contract drift across three independently evolved layers.

Classification drift

The browser code decides eligibility from filename and object metadata, but its closed type union has no text representation. Correct metadata cannot select a renderer that does not exist.

Response-policy drift

The Console server separately decides whether a response may be inline. It still treats plain text and JSON as safe passive MIME types, while XML and HTML remain attachments. That server decision is not reflected in the frontend classifier.

Renderer drift

The old generic iframe remains after the set of reachable preview types became media-only. The code therefore suggests a capability that the type system can no longer invoke.

The repair must realign the three layers without making MIME metadata a security boundary.

Why same-origin iframe preview is rejected

X-Frame-Options: SAMEORIGIN is not a sandbox. It controls who may embed a response; it does not limit what code inside a same-origin frame can do.

If uploader-controlled HTML, XHTML, SVG, or active XML were ever served as an inline same-origin document, it could act with the Console origin. An HttpOnly cookie would prevent direct cookie reads, but it would not prevent authenticated same-origin requests. A permissive or accidentally widened MIME rule would then turn stored content into stored application code.

nosniff, Content Security Policy, and Content-Disposition remain useful defense in depth, but none replaces the core invariant:

untrusted object bytes
        |
        v
strict text decoder
        |
        v
React textContent

never:
iframe / innerHTML / DOMParser / XML parser / executable document

Product contract

The feature is a read-only text viewer, not a web previewer and not an online editor.

The user should be able to:

  • open a small eligible object from either the list or object-detail surface;
  • read whitespace-preserving source text in the existing preview modal;
  • select and copy text using browser-native behavior;
  • understand whether a failure is caused by size, encoding, permission, object replacement, or network error;
  • download the original bytes at any time.

The user must never be led to believe that:

  • formatted JSON is the stored object;
  • a partial XML document is complete;
  • replacement characters are original bytes;
  • an unsupported encoding has been decoded faithfully;
  • an active HTML/XML document has been safely “sanitized” and executed.

Goals and non-goals

Goals

  1. Preview small logs, text, JSON, and XML without a local download.
  2. Keep object content inert regardless of extension, MIME, or payload.
  3. Bound retained response bytes and rendered text to 1 MiB.
  4. Preserve the stored text rather than silently reformatting it.
  5. Keep list and detail actions consistent with permissions and type eligibility.
  6. Support current object versions and explicitly selected historical versions.
  7. Preserve anonymous-access and subpath-hosting behavior.
  8. Ship the feature in Console first, then consume that exact Console revision in SILO.

Non-goals

  • HTML or XHTML rendering.
  • XML parsing, XSLT, external entities, or schema validation.
  • Markdown rendering.
  • JSON pretty-printing.
  • YAML or CSV-specific behavior.
  • Editing or saving.
  • Syntax highlighting, line numbers, search, folding, ANSI rendering, or linkification.
  • Head, tail, or truncated previews for large objects.
  • Lossy decoding or automatic detection of GBK, UTF-16, Latin-1, or other encodings.
  • A new backend text-preview endpoint.
  • Changes to the existing SVG, media, PDF, download, share, or storage contracts.

An object such as notes.md may still be shown as raw text when its exact MIME type is text/plain. It does not gain Markdown semantics.

Eligibility contract

Eligibility is deliberately two-stage.

Stage 1: preserve the legacy media decision

Run the current image, PDF, audio, and video classifier unchanged. If it returns anything other than none, return that result.

This preserves historical behavior for conflicting filename and MIME combinations.

Stage 2: apply text fallback

Only after the legacy result is none:

  1. Reject final extensions .html, .htm, and .xhtml.

  2. Match the final filename extension case-insensitively against:

    • .log
    • .txt
    • .json
    • .xml
  3. Normalize Content-Type by removing parameters, trimming whitespace, and lowercasing it.

  4. Match the normalized MIME exactly against:

    • text/plain
    • application/json
    • application/xml
    • text/xml

An allowed extension or an allowed exact MIME is sufficient. Broad matches such as text/, substring tests, and application/+json are forbidden in this release.

The resulting matrix is normative:

Filename and MIME Result Reason
report.txt + image/png image Existing media decision wins.
report.json + application/pdf PDF Existing media decision wins.
server.LOG + application/octet-stream text Allowed extension, case-insensitive.
no extension + application/json; charset=utf-8 text Exact normalized MIME.
page.html + text/plain none Explicit active-extension exclusion.
page.txt + text/html text Extension admits it; HTML source remains inert text.
notes.md + text/plain text Exact MIME admits raw text, not Markdown rendering.
image.svg + image/svg+xml existing image path No new text or iframe path.

Filename and MIME affect product eligibility only. They never select an executable rendering mode.

Resource contract

The binary limit is:

MAX_TEXT_PREVIEW_BYTES = 1,048,576

Exactly 1 MiB is eligible. 1 MiB plus one byte is not.

Known sizes

  • If the selected version has a known size greater than the limit, do not request its body.
  • If its known size is zero, show the empty-file state.
  • If its known size is within the limit, begin a bounded request.
  • An absent size is not the same as zero; it enters the bounded unknown-size path.

The current list-to-modal handoff must therefore preserve undefined rather than converting it to zero with a truthy fallback.

Bounded request

For a small or unknown size, request:

Range: bytes=0-1048576

The extra byte is an over-limit sentinel.

The client must:

  1. Inspect Content-Range and Content-Length when present.
  2. Read the response as a stream rather than calling response.text() or building a complete Blob.
  3. Retain at most the limit plus the sentinel byte.
  4. Cancel immediately when the sentinel byte is observed.
  5. Enforce the same limit when the server ignores Range and returns 200.
  6. Render only after end-of-stream proves that the complete object is within the limit.

An over-limit object opens an explanation state with its known size, the 1 MiB policy, and a Download action. It never shows a prefix fragment.

Request identity and cancellation

A preview request is identified by:

bucket + object name + version ID

The request must use the existing generated API client or an equivalent base-path-safe helper so that it preserves:

  • same-origin credentials;
  • the current Console subpath;
  • version_id;
  • anonymous-mode X-Anonymous: 1;
  • current error handling and permission boundaries.

Close, object change, version change, bucket change, and component unmount must abort the active request and clear the old content.

Abort alone is insufficient. A generation token or invalidation flag must also prevent a response that already completed reading or decoding from updating a newer preview.

An aborted request is not an error and must not produce an error toast.

Encoding and fidelity

The first release supports strict UTF-8 only:

new TextDecoder("utf-8", { fatal: true })

Requirements:

  • handle the UTF-8 BOM without displaying it;
  • preserve Unicode text, emoji, tabs, LF, and CRLF;
  • reject invalid UTF-8 rather than inserting replacement characters;
  • reject decoded NUL characters as binary or unsupported content;
  • do not guess another encoding;
  • do not log or persist object text;
  • always retain Download as the original-byte escape hatch.

The unsupported-encoding state should explain:

This object is not valid UTF-8 text or contains binary data. Download it to inspect the original bytes.

JSON and XML are displayed exactly as decoded source text. The first release must not run JSON.parse followed by JSON.stringify: that can alter unsafe integers, duplicate keys, whitespace, lexical forms, and the text users copy.

Safe renderer

The success state renders one text node:

<pre>{content}</pre>

The implementation must not use:

  • iframe, object, or embed;
  • dangerouslySetInnerHTML or innerHTML;
  • DOMParser or an XML parser;
  • Markdown or HTML rendering;
  • an HTML data/blob URL;
  • per-line or per-token spans;
  • automatic links, ANSI escapes, or syntax markup.

One bounded text node keeps the DOM cost predictable and the security property inspectable.

The preformatted region uses a monospace font, preserves whitespace, defaults to no wrapping, owns both scrollbars, is keyboard focusable, and supports native selection and copy. No-wrap is intentional: it preserves aligned logs and avoids expensive layout of a single very long line.

UI states and permissions

The Preview action is enabled only when:

eligible preview type
AND object read permission
AND not a delete marker
AND not a prefix

The object-detail conjunction bug must be fixed, and list and detail surfaces must share the same eligibility function.

An eligible over-limit object still offers Preview. The modal explains why content is not loaded; disabling the button would leave the user unable to distinguish size, permission, and type failures.

The modal distinguishes:

State Required behavior
Loading Accessible busy state; no stale text.
Success Scrollable raw text plus Download.
Empty Explicit “File is empty” state.
Too large Object size, 1 MiB limit, Download; no body request when size is already known.
Invalid UTF-8 / binary Dedicated explanation and Download.
Forbidden Permission-specific message; no retained text.
Not found / replaced Object-change message; no retained text.
Network / server error Actionable retry/download state.
Aborted / closed Silent cleanup.

HTTP error bodies must never be decoded and displayed as object content.

All new user-facing strings go through the existing translation layer and ship in English and Chinese together. The content region and controls must remain usable in light and dark themes and at narrow widths.

Functional and security requirements

Functional requirements

  • FR1: Existing media and PDF classification remains unchanged.
  • FR2: The text fallback follows the normative extension/MIME matrix.
  • FR3: Eligible complete objects up to 1 MiB render as strict UTF-8 source.
  • FR4: Over-limit objects render no partial content.
  • FR5: Empty objects have a distinct successful empty state.
  • FR6: Current and selected historical versions use the same version for metadata, size, and body.
  • FR7: Anonymous access and subpath hosting retain their current request behavior.
  • FR8: List and detail actions apply the same type and permission decision.
  • FR9: Download, share, media, PDF, and storage behavior do not change.

Security requirements

  • SR1: Object bytes can reach the DOM only through text content.
  • SR2: Text Preview contains no document renderer or parser.
  • SR3: At most 1 MiB plus one sentinel byte is retained.
  • SR4: Closing or changing identity invalidates every previous response.
  • SR5: Invalid UTF-8 and NUL content are not shown as faithful text.
  • SR6: Errors, Redux, local storage, logs, and telemetry never retain preview text.
  • SR7: Server authorization remains authoritative for direct requests.
  • SR8: No CSP or backend inline MIME relaxation is introduced.

Implementation scope

Expected Console changes:

  1. Refactor preview classification so the current media decision is preserved and text is an explicit fallback.
  2. Add text to the preview type union.
  3. Add a dedicated PreviewText component with streaming bounds, strict decode, request cancellation, and explicit states.
  4. Route text objects explicitly to that component.
  5. Remove the unreachable generic iframe fallback.
  6. Fix the object-detail Preview disable expression and share eligibility logic with the list surface.
  7. Preserve unknown size instead of coercing it to zero.
  8. Add English and Chinese strings.
  9. Add classification, component, resource, security, permission, version, and browser tests.

Expected unchanged areas:

  • Console and S3 API paths;
  • the backend safeMimeTypes list;
  • Content Security Policy;
  • object storage and metadata formats;
  • image, PDF, audio, video, download, and share handlers;
  • external frontend dependencies.

If a future product requires tailing, server-side transcoding, organization-wide policy, or reliable behavior through proxies that ignore Range, a dedicated server endpoint may be designed separately.

Rejected alternatives

Keep text preview disabled

Benefit: no new code or browser memory use.
Rejected because: logs and configuration objects are a routine object-storage workflow, and download-only inspection is an avoidable Console regression.

Reuse the same-origin iframe

Benefit: minimal code and browser-native presentation.
Rejected because: it turns uploader-controlled content and mutable MIME metadata into a same-origin document boundary. It also leaves resource use unbounded.

Add a backend preview API now

Benefit: central server-side limits and normalized text responses.
Rejected for the first release because: the user already has object-read permission, and the existing download endpoint provides versioning, authorization, and Range. A new API would duplicate contracts without establishing a new data-access boundary.

Show the first 1 MiB of a large object

Benefit: better large-log convenience.
Rejected because: partial JSON/XML is structurally misleading, UTF-8 boundaries need additional handling, and a single “preview” action would no longer mean complete content.

Decode invalid UTF-8 with replacement characters

Benefit: some damaged or legacy logs remain partially readable.
Rejected because: copied text would no longer faithfully represent the stored object. Lossy viewing and other encodings require a separate, explicit product mode.

Auto-format JSON

Benefit: more readable indentation.
Rejected because: parse/stringify can alter numbers, duplicate keys, lexical representation, and copied content. A future opt-in formatted view may sit beside, never replace, the raw default.

Add Monaco or another code editor

Benefit: line numbers, search, highlighting, and folding.
Rejected because: bundle, worker, CSP, and maintenance costs exceed the needs of a bounded read-only preview. A native <pre> is smaller and easier to audit.

Acceptance and test plan

Classification matrix

Automated tests must lock every normative matrix row, extension case handling, MIME parameter stripping, explicit HTML/XHTML denial, and unchanged media conflicts.

Resource tests

Cover:

  • 0 bytes;
  • 1 byte;
  • exactly 1,048,576 bytes;
  • 1,048,577 bytes;
  • known over-limit size with zero body requests;
  • unknown size;
  • 206 with a revealing Content-Range;
  • server ignores Range and returns 200;
  • missing or false Content-Length;
  • close and identity changes during streaming.

No case may retain or render more than the complete allowed object.

Encoding and fidelity tests

Cover UTF-8 Chinese, emoji, tabs, LF, CRLF, BOM, invalid byte sequences, NUL bytes, JSON unsafe integers, duplicate keys, original whitespace, XML declarations, DOCTYPE, CDATA, and stylesheet processing instructions.

The raw success view must preserve decoded text. Invalid and binary cases must show their dedicated state.

Security tests

Payloads containing <script>, event attributes, iframe tags, SVG handlers, XML stylesheets, external entities, and suspicious URLs must:

  • appear literally in <pre>.textContent;
  • create no corresponding DOM elements;
  • execute no script or dialog;
  • cause no object-content-originated request;
  • encounter no iframe, object, embed, HTML parser, or XML parser in Text Preview.

Permission and race tests

Verify:

  • no GetObject means no usable action and no retained body;
  • historical versions require their corresponding permission;
  • metadata and body use the same version ID;
  • a late old response cannot replace a new object’s preview;
  • 401, 403, 404, 416, and 5xx bodies never become preview content;
  • anonymous access and Console subpaths do not regress.

Browser regression

Use a real SILO/Console test instance to inspect both English and Chinese routes, light and dark themes, and narrow and desktop widths. Media, PDF, download, share, and version workflows require smoke coverage alongside the new text states.

Delivery and completion gates

The change belongs to pgsty/silo-console, even though the user report is tracked in the SILO server repository.

Delivery is staged:

  1. Merge the focused Console source and test change.
  2. Pass TypeScript checking, production build, automated matrices, and real-browser security regression.
  3. Update Console release notes and regenerate the actual embedded web assets.
  4. Publish a Console version; a minor release is appropriate for the new visible capability.
  5. Update SILO’s github.com/minio/console => github.com/pgsty/silo-console replacement to the exact new pseudo-version.
  6. Build a SILO candidate from that exact dependency and repeat integration checks.
  7. Publish the SILO binary and image, naming the first version that contains the feature.

These are separate states:

Gate Meaning
Console PR merged Implementation exists in source.
Console assets/tag published Console is independently consumable.
SILO dependency updated SILO main has integrated the change.
SILO release published Users can obtain the feature.

Issue #17 should not be described as fixed for users merely because a local preview or Console source PR exists.

Trade-off summary

The accepted design favors:

  • explicit scope over a generic browser viewer;
  • complete small files over partial large files;
  • source fidelity over automatic formatting;
  • strict UTF-8 over silent lossy decoding;
  • one inert text node over a full editor;
  • the existing download API over a new backend contract;
  • a verifiable security invariant over convenient same-origin rendering.

The cost is real: large logs and legacy encodings still require download, and the first release has no search, line numbers, wrapping toggle, or highlighting. Those omissions are deliberate. They make the feature small enough to audit and strong enough to trust.

Review record

The design was independently reviewed from three perspectives:

  • product scope, delivery, and acceptance;
  • security and frontend architecture;
  • compatibility and current-source verification.

The reviewers initially differed on MIME-only eligibility and lossy UTF-8 fallback. After cross-review they reached a single contract:

  • existing media classification wins;
  • text fallback accepts the four target extensions or four exact normalized MIME types;
  • HTML/XHTML extensions are explicitly excluded;
  • strict UTF-8 and NUL rejection are required;
  • lossy viewing is deferred to a separate proposal.

No unresolved design question remains. Implementation may proceed against this record.

3 - Why CompleteMultipartUpload Must Return ChecksumType: Review of PR #57

This is the design, review, and decision record for SILO #47 and PR #57.

Status on 2026-08-26: PR #57 is open and mergeable. Its code review verdict is GO WITH NON-BLOCKING NOTES, with high confidence. The four fork workflows still await maintainer approval, so the PR is not merged and no release artifact contains it.
Scope: return the already-known checksum type from CompleteMultipartUploadResult; do not add new checksum algorithms.
Owner: pgsty/silo, the SILO server repository.
Release boundary: code review, merge, a green main, a tagged release, packages, container images, deployment, and production verification are separate gates.

Too Long; Didn’t Read (TL;DR)

SILO already computed and persisted the correct checksum type for a completed multipart object. HEAD, ListParts, and GetObjectAttributes could expose it. The completion response could not, because its Go response struct had checksum value fields but no ChecksumType field.

PR #57 adds that field, copies the existing value from the checksum map, registers the new exported symbol in the compatibility baseline, and tests FULL_OBJECT, COMPOSITE, and the no-checksum case. It does not recalculate data, change metadata, migrate objects, or weaken integrity checks.

The repair is correct and intentionally narrow. Before merge, maintainers must approve and run all pending GitHub Actions, require every workflow to pass, and preserve the contributor’s DCO trailer in the final squash commit.

Where the defect came from

The defect was found while investigating #31, where a real boto3 client exposed several adjacent multipart-checksum incompatibilities. #31 was the data-path failure: a FULL_OBJECT CRC32 multipart upload could fail at completion. That problem was fixed independently.

After the object completed successfully, another inconsistency remained:

complete_multipart_upload() -> ChecksumType: None
head_object()               -> ChecksumType: FULL_OBJECT

AWS S3 returned FULL_OBJECT in both places. SILO returned the checksum value in the completion XML, and the committed object retained the correct type, but the completion SDK result exposed a null type.

That observation became #47. It is a presentation defect, not a checksum-calculation or storage defect. It does not explain the earlier InvalidPart failure from #31, and repairing it does not replace the server-side part-checksum work tracked in #46.

The S3 response contract

The AWS CompleteMultipartUpload API defines ChecksumType as an element of CompleteMultipartUploadResult. Its valid values are:

Value Meaning
FULL_OBJECT The reported checksum covers the logical bytes of the completed object.
COMPOSITE The object checksum is derived from the checksums of its multipart parts.

When an object has no additional S3 checksum, the element should be absent. A server must not invent a type with no checksum value.

This distinction matters to clients. The same Base64 field name can describe either a direct full-object checksum or a multipart composition. A client that validates the completion result needs the type to interpret the checksum correctly and to compare the response with the mode selected at CreateMultipartUpload.

What SILO did before the PR

The completion handler already passed the committed ObjectInfo to generateCompleteMultipartUploadResponse. That generator already called:

cs, _ := oi.decryptChecksums(0, h)

The checksum decoder returned a map containing both the algorithm value and the normalized object type:

CRC32                 -> "...Base64..."
x-amz-checksum-type    -> "FULL_OBJECT" or "COMPOSITE"

The response struct copied the values for CRC32, CRC32C, CRC64NVME, SHA1, and SHA256. It simply had nowhere to put the type:

committed ObjectInfo.Checksum
        -> decryptChecksums
        -> checksum values + x-amz-checksum-type
        -> CompleteMultipartUploadResponse
        -> checksum values copied, type discarded
        -> XML without <ChecksumType>
        -> SDK returns None / null

Other surfaces used the same state correctly. ListParts and GetObjectAttributes already returned ChecksumType; HEAD also reported the stored type. The loss was isolated to the success XML for CompleteMultipartUpload.

What PR #57 changes

The PR contains one signed-off commit, three files, 60 added lines, and no deletions. Only two production lines change.

Add the response field

ChecksumType string `xml:"ChecksumType,omitempty"`

omitempty is part of the compatibility contract: checksum-free uploads retain the old XML shape.

Copy the existing normalized value

ChecksumType: cs[xhttp.AmzChecksumType],

The generator does not infer the type from an ETag, algorithm name, or part count. It uses the same decoded metadata that already supplies the checksum values.

Test the response surface

The added test covers:

  • no checksum: the Go field is empty and <ChecksumType> is absent;
  • a full-object checksum: the field is FULL_OBJECT and the tag is present;
  • a multipart composite checksum: the field is COMPOSITE and the tag is present.

It checks the response value before XML encoding and separately checks omission/presence after encoding.

Record the exported compatibility symbol

CompleteMultipartUploadResponse.ChecksumType is an exported Go field. SILO’s rebrand guard performs an exact comparison of the exported compatibility surface, so the PR correctly adds the field to buildscripts/rebrand-guard/compat-baseline.json. This is an acknowledgement of an intentional public surface change, not a bypass of the guard.

Why the repair works

The correctness argument is a short chain of existing invariants.

  1. ObjectInfo.Checksum is the committed checksum metadata. The completion response is generated only after the object layer returns the committed ObjectInfo.
  2. decryptChecksums(0, h) uses the existing metadata-decryption path, including the request headers needed for SSE-C. No second decryption mechanism is added.
  3. The checksum decoder writes x-amz-checksum-type only when it has decoded a non-empty checksum value.
  4. Existing ChecksumType.ObjType() logic normalizes reachable states to FULL_OBJECT or COMPOSITE.
  5. Indexing a nil or missing map entry returns the empty string.
  6. XML omitempty removes the element for that empty string.

The resulting behavior is deterministic:

Committed checksum state Map value Completion XML
No additional checksum empty no <ChecksumType>
Full-object checksum FULL_OBJECT <ChecksumType>FULL_OBJECT</ChecksumType>
Multipart composite checksum COMPOSITE <ChecksumType>COMPOSITE</ChecksumType>

The change is therefore a missing projection from established state to the wire response. It does not create new checksum state and cannot make an incorrect checksum correct. It makes the response describe the state the server has already validated and committed.

Review and verification

The PR was reviewed against GitHub’s current synthetic merge commit, whose parents were the latest main and the PR head. Although the contributor branch was 12 commits behind its original base, the current merge result was clean and compiled with the intervening checksum work on main.

Local verification on that exact merge result included:

targeted ChecksumType regression test
CGO_ENABLED=0 go test ./cmd/ -count=1 -timeout 30m
go vet ./cmd/
gofmt and git diff --check
rebrand compatibility guard
local DCO rule

The full cmd test completed successfully in 137.598 seconds. The commit author email matches its Signed-off-by trailer. Cryptographic Git commit signing is independent of DCO and is not required by this repository.

A separate read-only local Claude Code adversarial review inspected the merged diff, checksum serialization, XML path, current main, tests, DCO, and compatibility guard. Its verdict was GO WITH NON-BLOCKING NOTES, with high confidence on correctness, compatibility, security, and mergeability.

Evaluation of the PR

What is strong

  • The scope matches the defect. Two production lines restore one missing response element.
  • It reuses authoritative state. There is no duplicate type derivation and no new checksum algorithm branch.
  • Backward compatibility is explicit. omitempty preserves checksum-free responses.
  • The test covers both valid values and absence. A regression cannot silently restore the null result.
  • The compatibility baseline is updated deliberately. CI is not weakened.
  • DCO provenance is complete. The sole commit has a matching sign-off.

Non-blocking review notes

The test is correct for the changed generator but its fixtures are not byte-for-byte models of every production multipart metadata flag:

  • the FULL_OBJECT fixture reaches the right value through a non-multipart checksum state rather than a completed multipart state carrying ChecksumMultipart, ChecksumIncludesMultipart, and ChecksumFullObject;
  • the COMPOSITE fixture carries the multipart flag but omits the persisted per-part checksum block.

Existing API-level tests already exercise genuine FULL_OBJECT and COMPOSITE completion and verify their committed types. PR #57 tests the remaining projection from decoded state to the response field and XML. Adding an assertion to those full API tests would improve test fidelity, but it is not required for this two-line repair.

The PR places ChecksumType before the algorithm-specific fields, while AWS’s example response and SILO’s newer CopyObjectResponse place it after them. Mainstream S3 SDKs parse XML by element name, so this is a parity and style detail rather than a compatibility blocker. Moving the field is optional.

Finally, the commit title says feat: even though the PR correctly marks itself as a bug fix. The squash subject should use fix:; the contributor does not need to amend the code for that cleanup.

Why new algorithms do not belong in this PR

AWS now documents additional fields such as SHA512, MD5, and XXHASH variants. Adding those XML fields alone would create false compatibility.

SILO’s current checksum implementation supports CRC32, CRC32C, CRC64NVME, SHA1, and SHA256. A real new algorithm requires coordinated support across:

  • request header parsing and validation;
  • streaming checksum calculation;
  • multipart FULL_OBJECT or COMPOSITE semantics;
  • on-disk checksum encoding and decoding;
  • UploadPart, UploadPartCopy, completion, copy, replication, HEAD, GET, ListParts, and GetObjectAttributes;
  • SDK/client interoperability and a full encrypted/compressed/versioned test matrix.

PR #57 should not grow response-only placeholders for algorithms the server cannot calculate or persist. Each new algorithm family needs a separate compatibility decision, implementation, and review.

Compatibility and operational impact

  • S3 clients: checksum-aware clients receive ChecksumType from future successful multipart completions instead of null.
  • Wire format: one additive XML element appears only when an additional checksum exists. Clients that ignore unknown elements remain unaffected.
  • Integrity: no checksum is recalculated or accepted differently. Existing validation semantics are unchanged.
  • Stored data: no object, part, metadata, or erasure format changes. No migration or backfill.
  • Existing objects: object state remains correct. A past completion response cannot be replayed; use HEAD or GetObjectAttributes to inspect an existing object’s type.
  • Encryption: the response uses the established checksum metadata-decryption path. No key material or new secret is exposed.
  • Performance: one map lookup and one optional XML element; no extra object read, hashing pass, or allocation proportional to object size.
  • Rolling upgrade: old nodes omit the element and new nodes return it. Requests and stored objects remain compatible, but client-visible behavior stabilizes only after all serving nodes are upgraded.
  • Rollback: rolling back removes the response element from future completions; it does not damage objects created while the fix was present.
  • Other repositories: no server dependency, silo-pkg, MCLI, or Console change is required. Public documentation belongs in this site.

This is an additive compatibility repair, not a release feature that requires operators to rewrite data. Its only externally visible effect is a more complete success response.

Merge and release decision

The final code-review decision is accept PR #57 after remote CI is green.

Before merge:

  1. inspect the fork diff and approve the pending GitHub Actions;
  2. require DCO, Go CI, Test Release Pipeline, and VulnCheck to pass;
  3. confirm the tested merge ref still includes the current main if main moves again;
  4. squash with a bug-fix subject such as fix: return ChecksumType from CompleteMultipartUpload;
  5. preserve Signed-off-by: Shooks <[email protected]> in the final squash commit body.

No code expansion, rebase, dependency update, storage migration, or cross-repository implementation is required to merge this PR. The PR’s Resolves #47 relationship should close the issue after merge.

After merge, a green main proves repository integration. It still does not prove that a SILO release, package, container image, deployment, or production endpoint contains the repair. Those gates must be recorded separately.

Conclusion

PR #57 is a good example of a small compatibility fix whose correctness comes from respecting an existing source of truth. The checksum type was already calculated, validated, persisted, decryptable, and visible through other APIs. The completion response simply failed to project it into XML.

The accepted repair does exactly that projection and nothing more. It makes the wire response honest without touching user data, checksum mathematics, storage layout, or algorithm scope. The remaining work is operational discipline: run the untrusted fork workflows, keep DCO provenance through squash, merge only on green, and distinguish that merge from a shipped release.

4 - When the Total Is Unknown: Folder Download Progress

PRD for replacing NaN% with truthful indeterminate progress when SILO Console downloads a streamed folder ZIP, without changing the server API or ordinary file downloads.

Status: Implemented and verified locally; commit, Console release, and Silo dependency update pending · Priority: P1 · Owner: pgsty/silo-console · Related issue: pgsty/silo#62 · PRD review: Claude Fable 5 (xhigh) — APPROVE · Implementation review: Claude Fable 5 (xhigh), 2026-08-23 — APPROVE, no P0/P1/P2 findings

SILO Console shows NaN% in Downloads / Uploads while downloading a folder. The ZIP normally keeps streaming and the stored objects are intact, but the progress bar has crossed from “unknown” into an invalid determinate state. Users see a full-looking bar, assume the transfer failed or finished, and retry it.

The proposed repair is intentionally narrow:

A download may enter determinate mode only when it has a finite, positive total measured in bytes applicable to that response. Without such a total, it remains indeterminate until completion, failure, or cancellation.

The server keeps streaming ZIPs. Ordinary files keep their percentages. The frontend gains one safe calculation boundary, reuses its existing indeterminate renderer, and closes one missing cancellation transition. This record defines why that is both sufficient and the smallest truthful fix.

The observed failure

The defect is present in the current silo-console v2.1.1, which is embedded by Silo RELEASE.2026-08-06T00-00-00Z.

Reproduction:

  1. Put several objects below a prefix such as folder/.
  2. Stay in the parent listing, select folder/, and click Download.
  3. Open Downloads / Uploads before the transfer finishes.
  4. The row displays NaN%; the ZIP request continues.

The runtime check used a prefix containing about 88.7 MiB and throttled Chromium to preserve the observation window. Two independent downloads produced the same NaN% state.

This is a frontend correctness bug. It is not evidence of corrupted objects, an altered disk format, or a failed S3 GET.

What is actually happening

The visible NaN% is the end of a contract mismatch across three layers.

A prefix has no object size

S3 folders are common prefixes, not stored directory objects. In the listing model, a prefix ends in / and carries size=0. The Console already renders that size as -, correctly treating it as not applicable.

The generated API model marks size as omitempty, so logical zeroes are absent from listing JSON. The single-selection thunk nevertheless passes object.size straight into the download helper: a prefix or zero-byte object therefore supplies undefined at runtime (while synthetic prefix records may supply 0). Neither value is a valid denominator.

A streamed ZIP has no known wire length

The server recognizes the trailing /, recursively lists the objects, then connects a zip.Writer to an io.Pipe. Objects are read, deflated, and copied to the HTTP response as the archive is produced.

That behavior is desirable: the server can send the first bytes without holding the complete archive in memory or on disk. Its consequence is equally deliberate: the final compressed byte length does not exist when headers are sent, so the response has Content-Type: application/zip and a filename, but no Content-Length.

The sum of source object sizes is not a substitute. Source sizes are uncompressed bytes; ProgressEvent.loaded counts response bytes after ZIP compression and framing. They are different units.

A progress event does not imply a computable percentage

The client currently computes every event as:

Math.round((event.loaded / fileSize) * 100)

For a prefix, the denominator is zero or absent. Depending on the value and event, JavaScript produces NaN (loaded / undefined or 0 / 0) or Infinity (positive bytes divided by zero).

The progress callback then writes that non-finite value into Redux and sets waitingForFile=false. That second operation is the decisive state error: the task leaves the existing indeterminate branch merely because an event arrived, not because the event contained a usable total. The determinate progress component receives the invalid value and renders an invalid label.

The complete chain is:

common prefix: size = 0
        |
        v
download(..., fileSize = 0)
        |
        v
streamed deflated ZIP, no Content-Length
        |
        v
event.loaded / 0 => NaN or Infinity
        |
        v
invalid percentage enters Redux; waitingForFile becomes false
        |
        v
determinate ProgressBar renders NaN%

Ordinary non-empty files avoid the defect because the server can stat the object, sets Content-Length, and the list size is positive. If the browser emits a progress event for an empty response, a zero-byte file reaches the same arithmetic boundary as a prefix even though it is a real object; it therefore belongs in the regression contract.

Product contract

The UI needs one honest distinction:

  • Determinate means both transferred bytes and total bytes are known in the same unit.
  • Indeterminate means the request is active but the total is unknown.

This yields four load-bearing invariants:

determinate  => total is finite and total > 0
determinate  => percentage is finite and 0 <= percentage <= 100
unknown total => indeterminate
terminal state => not indeterminate

These invariants are more general than objectPath.endsWith("/"): they cover prefixes, zero-byte files, malformed metadata, and any future unknown-length response without inventing object-type exceptions.

Goals and non-goals

Goals

  1. A folder download never displays NaN%, Infinity%, or a fabricated percentage.
  2. Unknown-length transfers use the existing indeterminate animation.
  3. Known-length ordinary files retain their current percentage behavior.
  4. Completion, failure, and cancellation always leave indeterminate mode.
  5. A zero-byte file never produces a non-finite percentage and still reaches success.
  6. No non-finite or out-of-range download percentage enters Redux.
  7. The fix can ship in Console first and then be consumed by Silo as a dependency update.

Non-goals

  • Do not pre-generate or buffer a complete ZIP on the server.
  • Do not use the sum of uncompressed object sizes as network progress.
  • Do not redesign the entire Object Manager state model.
  • Do not route folders through the current immediately-completing BrowserDownload path.
  • Do not solve the browser memory cost of XMLHttpRequest.responseType="blob" here.
  • Do not change whether a cancelled row remains visible until the user clears it.
  • Do not redesign mid-stream ZIP error signaling after HTTP headers have been sent.
  • Do not modify the S3 API, Console API, object layout, or archive contents.

Those are legitimate follow-ups, but coupling them to this defect would enlarge risk without being necessary to restore truthful progress.

The decision

The minimum production repair has four parts.

D1. Calculate only from a valid total

Add a small pure function, separate from DOM and Redux side effects:

type DownloadProgressEvent = Pick<
  ProgressEvent,
  "loaded" | "lengthComputable" | "total"
>;

export const calculateDownloadPercent = (
  event: DownloadProgressEvent,
  objectSize: number,
): number | null => {
  let total: number | null = null;

  if (Number.isFinite(objectSize) && objectSize > 0) {
    total = objectSize;
  } else if (
    event.lengthComputable &&
    Number.isFinite(event.total) &&
    event.total > 0
  ) {
    total = event.total;
  }

  if (
    total === null ||
    !Number.isFinite(event.loaded) ||
    event.loaded < 0
  ) {
    return null;
  }

  return Math.min(
    100,
    Math.max(0, Math.round((event.loaded / total) * 100)),
  );
};

The source priority preserves compatibility:

  1. A finite positive objectSize retains the current ordinary-file calculation.
  2. If object size is unavailable but the browser declares the response length computable and supplies a finite positive event.total, use it.
  3. Otherwise return null: no truthful percentage exists yet.

The helper’s output contract is complete: either null, or a finite number in [0,100].

D2. Keep unknown totals indeterminate

Change the XHR handler to dispatch only a real percentage:

req.addEventListener("progress", (event) => {
  const percent = calculateDownloadPercent(event, fileSize);

  if (percent !== null) {
    progressCallback(percent);
  }

  // No valid total: preserve waitingForFile=true so the existing UI remains
  // indeterminate instead of manufacturing a determinate value.
});

Download rows already start with waitingForFile=true, and ObjectHandled already renders that state with variant="indeterminate". There is no need to widen Redux to number | null, add another boolean, or change MDS.

When the first valid percentage arrives, the existing updateProgress action stores it and sets waitingForFile=false. When no valid percentage ever arrives, the row remains indeterminate until a terminal action.

D3. Make cancellation terminal

Completion and failure already clear waitingForFile. Cancellation does not. Add the missing transition in cancelObjectInList:

item.waitingForFile = false;

Without that line, the repaired prefix download would remain in the indeterminate rendering branch after abort, masking the Cancelled state. The row continues to follow the current product behavior: it remains as a cancelled record and can be removed manually. Automatic removal is not part of this change.

There is one event-order guard at the XHR boundary as well. abort() first produces readystatechange(DONE, status=0) and only then the abort event; without a status-zero return, the generic DONE branch marks the request failed before onabort can mark it cancelled. DONE/status zero is therefore left to the dedicated onerror or onabort handler, and onabort removes the stored request reference.

D4. Normalize an omitted zero-byte size

The single-selection thunk passes object.size || 0, matching the other download entry point. This restores the API model’s omitted logical zero before the helper checks Blob.size === fileSize, so an HTTP 200 zero-byte object completes at 100% instead of being reported as incomplete.

D5. Keep the server stream unchanged

The folder handler continues to generate a deflated ZIP through io.Pipe and omit Content-Length. No API, archive, storage, or resource-management contract changes.

State machine

State waitingForFile percentage Terminal flag Rendering
Queued / no valid progress yet true 0 none indeterminate
Unknown-total transfer true 0 none indeterminate
Known-total transfer false 0..100 none determinate percentage
Completed false 100 done=true success
Failed false last value failed=true, done=true error
Cancelled false 0 cancelled=true, done=true cancelled

The state does not move back from determinate to indeterminate. If a later event lacks a valid total after a valid percentage was observed, the handler simply retains the last valid value.

Failed and Cancelled both set done=true in the existing reducers. ObjectHandled uses done to change its close button from “abort request” to “remove record”; this repair preserves that behavior. The cancelled Redux value remains 0, while the existing ProgressBarWrapper renders a full orange terminal bar with a Cancelled label because ready=true. That established presentation is not part of this repair.

waitingForFile is not the ideal long-term name for “no computable progress.” Renaming it or replacing the booleans with a discriminated union would improve the model, but that is a separate refactor. In this repair, the field already expresses and renders the required state, so reusing it minimizes compatibility risk.

Why this is sufficient

The repair closes the bug by cases.

Ordinary non-empty file

objectSize > 0, so the helper uses the same denominator as today. The result is finite and clamped, updateProgress enters determinate mode, and completion still sets 100%.

Current streamed folder

objectSize is normalized to 0, while lengthComputable=false and event.total=0. The helper returns null; no invalid action is dispatched, so the row remains indeterminate. Completion sets waitingForFile=false, percentage=100, and done=true.

Future response with a real length

If a proxy or later server implementation provides a trustworthy response total, lengthComputable=true and event.total>0. The same code automatically produces a real percentage without another product change.

Zero-byte file

The omitted listing size is normalized to zero, and both totals are then zero, so an intermediate percentage is mathematically undefined. The row stays indeterminate for its usually brief lifetime; the zero-byte Blob now equals the normalized expected size, and the successful response transitions directly to 100%. 0/0 is never evaluated.

Failure and cancellation

Failure already exits indeterminate. The added cancellation transition does the same on abort. No terminal row can continue to look active merely because its total was unknown.

Mathematically, division occurs only when total belongs to (0, +infinity). The result is then clamped to [0,100]. Therefore neither NaN nor Infinity can cross the calculation boundary into Redux or the determinate renderer.

Rejected alternatives

Buffer the ZIP to obtain Content-Length

The server could generate the complete archive in memory or a temporary file, measure it, and then send it. That would provide an exact wire total, but at the cost of memory or disk pressure, delayed first byte, cleanup complexity, and worse concurrent-download behavior. An observability defect does not justify discarding streaming.

Sum the objects under the prefix

That sum is uncompressed logical data. event.loaded measures compressed response bytes plus ZIP framing. The units differ, so the bar could stop below 100%, exceed 100%, or move according to compression ratio rather than transfer completion. Reject.

Convert invalid progress to 0%

This hides the string but lies about the state: determinate 0% means the total is known and no portion has transferred. Users would still interpret the transfer as stalled. Unknown must remain unknown.

Special-case paths ending in /

That fixes the reported prefix but misses a real zero-byte object, invalid metadata, and other unknown-length responses. The correct boundary is denominator capability, not object type.

Send folders through BrowserDownload

The current large-file path creates an anchor and immediately calls the completion callback after clicking it. It cannot report true completion, console-managed cancellation, or a subsequent HTTP failure. It may be the basis of a later streaming-download design, but today it would replace one lie with another.

Sanitize inside ProgressBar

A generic component guard could be useful defense in depth, but it would leave invalid data in Redux and hide the broken state transition from every other consumer. The primary repair belongs where progress becomes application state.

Introduce percentage: number | null now

A discriminated progress state would be cleaner than the current booleans if the Object Manager were being redesigned. Adding null while retaining waitingForFile, done, failed, and cancelled would instead create more contradictory combinations. Removing the old fields is larger than this bug requires. Reuse the already-rendered indeterminate state now; redesign it separately.

Requirements and acceptance

Functional requirements

  • FR1: An unknown total keeps the task indeterminate.
  • FR2: A finite positive object size preserves ordinary-file percentages.
  • FR3: A finite positive event.total is a fallback only when lengthComputable=true.
  • FR4: Every dispatched percentage is finite and within [0,100].
  • FR5: A zero-byte file never displays non-finite progress and reaches success.
  • FR6: Completion, failure, and cancellation leave indeterminate mode.
  • FR7: Versioned objects, anonymous downloads, previews, and long-filename entry points retain their existing call contract.

Non-functional requirements

  • No new server CPU, memory, disk-buffer, or request cost.
  • No new frontend dependency or build step.
  • No change to the S3 API, Console API, ZIP content, or stored objects.
  • The calculation must be testable without a DOM or live store.
  • TypeScript typecheck and the production frontend build must pass.

Acceptance criteria

  1. While a folder ZIP without Content-Length is active, its row shows an indeterminate animation and no percentage text.
  2. On successful completion, the row reports success/100% and the ZIP can be opened.
  3. A normal non-empty file continues to show finite determinate progress and completes at 100%.
  4. A zero-byte file never shows NaN% or Infinity% and completes successfully.
  5. Cancelling an unknown-total download aborts the request and shows Cancelled, not an active animation.
  6. No download path can place a non-finite or out-of-range percentage in Redux.

Test plan

Pure calculation matrix

Use the existing @playwright/test runner for the pure module rather than adding a test framework. This needs one config-only addition in web-app/playwright.config.ts: a dependency-free unit project, for example with testMatch: /.*\.unit\.ts/. The existing chromium project depends on the auth setup against a live Console at localhost:9090; pure calculation and reducer tests must not be gated by that environment. No new dependency is introduced.

Case loaded objectSize lengthComputable event.total Expected
Ordinary file, halfway 50 100 false 0 50
Common prefix 1024 0 false 0 null
Initial zero over zero 0 0 false 0 null
Response-total fallback 50 0 true 200 25
Zero total is unusable 0 0 true 0 null
Loaded exceeds total 150 100 true 100 100
Invalid object size 10 NaN false 0 null
Omitted zero size 10 undefined false 0 null
Invalid response total 10 0 true Infinity null
Negative loaded -1 100 true 100 null

State tests

Cover the transition contract directly:

  1. A new download starts with waitingForFile=true.
  2. No valid progress action means it remains indeterminate.
  3. Valid progress produces a finite value and waitingForFile=false.
  4. Complete produces done=true, waitingForFile=false, percentage=100.
  5. Failure produces failed=true, done=true, waitingForFile=false.
  6. Cancel produces cancelled=true, done=true, waitingForFile=false, percentage=0.

Browser regression

Use the real Console test instance and Chromium:

  1. Create a temporary bucket with several objects below folder/.
  2. Select the prefix from its parent and start the download.
  3. Apply CDP download throttling so the intermediate state is observable. Throttled runs must raise the default 30-second test timeout with test.setTimeout.
  4. Open Downloads / Uploads and verify that the row exists, has no percentage label, and contains neither NaN% nor Infinity%.
  5. Cancel it and verify the Cancelled terminal state.
  6. Restore network conditions in finally.
  7. Download again without throttling, wait for the browser download, and verify the ZIP.
  8. Repeat the relevant assertions for one ordinary non-empty file and one zero-byte file.
  9. Remove the bucket, objects, downloads, and temporary files in teardown.

The current Playwright project is Chromium-only, so CDP is an acceptable test mechanism. If Firefox or WebKit projects are later enabled, keep the pure and state tests cross-browser and gate only the throttled observation behind the Chromium project.

Implementation boundary

Expected Console changes:

  1. Add downloadProgress.ts containing the pure calculation.
  2. Change Objects/utils.ts to dispatch only a non-null percentage, let status-zero terminal events reach their dedicated handlers, and clean up an aborted request.
  3. Normalize omitted zero sizes in the single-selection thunk.
  4. Change cancelObjectInList to clear waitingForFile.
  5. Add calculation, state, and browser regression coverage using existing dependencies, with a dependency-free unit project in playwright.config.ts.

Expected unchanged code and contracts:

  • The Go folder-download handler and its streaming ZIP.
  • ObjectHandled, ProgressBarWrapper, and MDS.
  • IFileItem.percentage: number and the existing thunk callback types.
  • S3 and Console API routes.
  • Stored object and archive formats.

Delivery and rollback

The fix belongs in pgsty/silo-console, not the Silo server repository where the issue was reported.

Delivery order:

  1. Transfer or cross-reference issue #62 to pgsty/silo-console.
  2. Implement the bounded Console change.
  3. Pass typecheck, production build, pure/state tests, and real browser regression.
  4. Publish a new Console release.
  5. Update Silo’s pinned Console pseudo-version or release dependency.
  6. Build a Silo candidate and repeat folder, ordinary-file, zero-byte, cancel, and ZIP-integrity checks.
  7. Publish Silo and record both affected and fixed versions on the issue.

There is no data migration. If the frontend change regresses, Silo can roll back only the Console dependency; server data and API behavior remain compatible.

Definition of done

  • The calculation returns only null or a finite [0,100] number.
  • Active unknown-total folder downloads render indeterminate.
  • Ordinary files retain determinate progress.
  • Zero-byte files never render invalid progress.
  • Complete, failed, and cancelled rows all leave indeterminate mode.
  • The streamed ZIP and server response contract remain unchanged.
  • Typecheck, production build, and automated regressions pass locally.
  • A Console release is published.
  • Silo updates the Console dependency and passes candidate verification.

Follow-up work

Four adjacent improvements deserve separate design records:

  1. Stream large folder downloads directly to the browser or filesystem instead of holding the full Blob in memory.
  2. Replace the Object Manager’s boolean combination with a discriminated progress/terminal state.
  3. Improve end-to-end integrity and error signaling for ZIP failures after headers have been sent.
  4. Add a generic non-finite-value guard to shared progress components as defense in depth.
  5. Repair the pre-existing Blob JSON error decoder and request-trace cleanup on HTTP failure paths.

None is required to stop the current UI from lying. The next maintenance iteration should first restore the smallest honest contract: known totals get percentages; unknown totals remain unknown.

5 - Optional Checksums, Mandatory Failure: Repairing UploadPart and UploadPartCopy Compatibility

This is the complete design and implementation record for SILO #46. The repair was not merely a changed if statement. One apparently optional S3 header reached into multipart completion semantics, copy responses, compression and encryption pipelines, compatibility baselines, and release verification.

Status: server implementation and local verification complete; commit, PR, remote CI, release, and production verification pending.
Owner: pgsty/silo, the SILO server repository.
Tracking: #46.
Independent follow-ups: #63 CopyObject + compression checksum, #64 federated UploadPartCopy checksum.
Adversarial review: local Claude Code, Fable 5, --effort max; final verdict GO, with no blocking findings.

Too Long; Didn’t Read (TL;DR)

A multipart upload splits a large file into smaller parts. A client may attach a checksum to each part so the server can verify the transfer, but AWS defines that checksum as optional. SILO used to treat it as mandatory: an ordinary UploadPart failed without one, and UploadPartCopy could never work because it has no part-body checksum to provide.

After the repair, SILO still validates a checksum when the client sends one. When the client omits it, SILO computes the checksum while reading the original bytes and saves the result. This happens before compression and encryption, requires no second read, and changes no on-disk format. The result is AWS-compatible behavior without weakening data integrity.

Decision

When a multipart upload declares a checksum algorithm in CreateMultipartUpload, SILO applies this contract:

  1. If the client supplies a part checksum, the server continues to validate it. A wrong value or algorithm fails and is never hidden by fallback computation.
  2. If the client omits the part checksum, the server computes it in one pass with the MPU algorithm over the logical plaintext stream, before compression and encryption, and persists the result.
  3. A normal UploadPart echoes a checksum response header only when the client supplied the checksum. A server-computed fallback is not echoed.
  4. UploadPartCopy has no client part-body checksum, so the server computes the value and returns it in CopyPartResult.
  5. ListParts returns the persisted part checksum.
  6. FULL_OBJECT completion continues to linearize the full checksum from stored part checksums. COMPOSITE completion continues to require a checksum for every part; clients can recover those values with ListParts.
  7. Computation occurs during the existing read. Completion never re-reads the entire object merely to manufacture missing state.

In one sentence:

The optional input is the client-provided checksum value, not the server’s responsibility to maintain a consistent checksum-enabled MPU.

How we found it

The defect surfaced while investigating a different multipart checksum issue, #31.

#31 concerned CompleteMultipartUpload: for FULL_OBJECT, a client can complete with part numbers, ETags, and an optional full-object checksum without retaining every part checksum in the completion XML. Tracing that path backward exposed a stronger, earlier condition in erasureObjects.PutObjectPart:

if cs := fi.Metadata[hash.MinIOMultipartChecksum]; cs != "" {
    if r.ContentCRCType().String() != cs {
        return InvalidArgument{/* checksum missing */}
    }
}

Once an MPU declared a checksum algorithm, every UploadPart had to carry the matching x-amz-checksum-* value. Omitting it returned:

400 InvalidArgument:
checksum missing, want "CRC32", got ""

API-level probes reproduced the behavior on both the single-drive and erasure backends.

Reviewing CopyObjectPartHandler raised the severity from a client-configuration incompatibility to P0. UploadPartCopy has no request body for the caller to checksum. The handler reads the source object, constructs an internal reader, and eventually enters the same PutObjectPart implementation. There is no client header and no SDK setting that can repair the request. Every checksum-enabled MPU therefore rejected UploadPartCopy by construction.

What AWS requires

This cannot be decided by saying that MinIO has historically behaved a certain way. The S3 protocol is the authority.

The AWS UploadPart API describes each algorithm-specific checksum header as something that “can be used as a data integrity check.” More importantly, its response fields say that the checksum is present only when it was provided in the request.

The AWS UploadPartCopy API is different: when the MPU was created with an algorithm, the copy result contains that part checksum. There is no copy request body, so this is necessarily a server-computed value.

The AWS ListParts API is the standard way to recover checksums for parts in an upload that is still in progress.

The algorithm/type matrix also rules out treating the repair as one Boolean flag:

Algorithm FULL_OBJECT COMPOSITE
CRC64NVME Supported Unsupported
CRC32 / CRC32C Supported Supported
SHA1 / SHA256 Unsupported Supported

FULL_OBJECT is limited to CRCs that can be linearized, but SHA1 and SHA256 still need correct per-part digests for COMPOSITE completion.

SDK configuration makes the gap practical. Current AWS SDKs usually calculate request checksums when an operation supports them, but users can choose request_checksum_calculation = when_required, and low-level callers can initiate an algorithm without repeating it on every part. S3 accepts those requests; SILO did not.

Why removing the check is not a fix

The most tempting patch is to delete the comparison and allow a checksum-less part to proceed. That only moves the failure to completion.

SILO does not reconstruct and re-read all object bytes during MPU completion. It reads ObjectPartInfo.Checksums from each part.N.meta:

  • a missing entry immediately becomes InvalidPart;
  • FULL_OBJECT calls Checksum.AddPart, combining digests with their part lengths;
  • COMPOSITE concatenates the raw digest bytes and hashes them into the object checksum.

The actual invariant is therefore:

checksum-enabled MPU
        => every committed part has a checksum for the MPU algorithm

Deleting the upload check without filling the metadata would make UploadPart appear successful, leave ListParts incomplete, omit the UploadPartCopy response value, and fail later during completion. A delayed failure is harder to diagnose than the original immediate one.

Alternatives considered

Option Benefit Fatal problem Decision
Delete the strict check Smallest diff Part metadata still lacks the checksum; completion must fail Rejected
Relax only FULL_OBJECT Unblocks some default CRC clients Leaves COMPOSITE and SHA incompatible; cannot close #46 Rejected
Re-read every part at completion Avoids storing a digest during upload Adds O(object size) second-pass I/O and still cannot fix ListParts or the copy response Rejected
Always return the server value from normal UploadPart Makes federation forwarding easy Violates the AWS response contract Rejected
Copy the AIStor implementation exactly Commercial precedent CRC-only fallback and a transformed-stream placement risk Rejected
Compute and persist in one pass over logical plaintext Complete protocol behavior, no second I/O, CRC and SHA support Requires an explicit plaintext checksum reader distinct from the storage reader Accepted

What the commercial edition taught us

We downloaded and verified the then-current MinIO AIStor RELEASE.2026-08-07T18-34-35Z. Without a commercial license the server enters offline mode and denies S3 operations, so the evidence came from Go pclntab and ARM64 disassembly, not a black-box compatibility run.

The static analysis showed that AIStor already:

  • installs a server hasher when the client checksum is absent;
  • persists the result in part metadata;
  • exposes checksum fields in CopyPartResult.

It nevertheless applies fallback only to CanMerge() algorithms—CRC32, CRC32C, and CRC64NVME. SHA1/SHA256 COMPOSITE still follows the old checksum missing path. More importantly, the hasher is attached in the object layer to the current r.Reader; under compression or encryption that reader may already represent transformed storage bytes.

AIStor validated the general direction—compute and store—but not an implementation that SILO could copy mechanically.

How adversarial review overturned the first design

The first plan tried to centralize every decision inside erasureObjects.PutObjectPart: read the MPU metadata in the object layer and install a server hasher when the incoming reader had no client checksum. It looked attractive because all internal callers would share one rule.

The first Fable 5 Max adversarial review found that this design was wrong for compression.

newS2CompressReader is not a lazy wrapper. Construction immediately launches a goroutine:

go func() {
    _, err := io.Copy(comp, r)
    // ...
}()

The S2 writer also reads several blocks concurrently. After constructing the compressor, the handler still performs option parsing, encryption preparation, and the object-layer call. By the time PutObjectPart installed a hasher, the plaintext reader could already have lost several MiB:

  • a large part would get a checksum with a missing prefix;
  • a small part could reach EOF before installation and produce no result;
  • mutating ServerSideHasher concurrently with Read would be a data race.

That finding changed the responsibility split:

The handler installs the hasher before any eager transform starts; the object layer validates the algorithm, requires a result, and persists it atomically.

This was the decisive turn in the design. Putting logic in the lowest layer may look more uniform, but stream correctness depends equally on when bytes begin moving and which representation of those bytes a layer can see.

Final implementation

A dedicated logical checksum reader

PutObjReader originally distinguished two concepts:

  • Reader, the stream sent to storage, possibly compressed or encrypted;
  • rawReader, used by older ETag and checksum code.

Under compression, even rawReader may not directly see plaintext; it can merely carry an ETag through an etag.Tagger chain. The repair therefore did not overload it. It added an unexported field:

checksumReader *hash.Reader

This reader always represents the logical S3 part bytes. WithEncryption can replace the storage Reader, but it must preserve checksumReader.

Unexported accessors on PutObjReader then:

  • return the effective client or server checksum type;
  • prefer the client value whenever it exists;
  • otherwise return the server result finalized at EOF.

Keeping the mechanism unexported minimizes public Go API growth and gives #63 a shared internal path without prematurely changing ordinary CopyObject behavior.

Preparing the hasher before transformations

prepareMultipartChecksumReader loads the algorithm and checksum type saved with the MPU:

  1. no declared algorithm means no work;
  2. an existing client checksum is compared by base algorithm;
  3. a wrong algorithm preserves the InvalidArgument rejection;
  4. an omitted client checksum installs the corresponding server hasher on the plaintext reader.

For normal UploadPart:

  • the compressed path prepares actualReader after request-checksum parsing but before newS2CompressReader;
  • the uncompressed path prepares the request hash reader before the encryption reader is constructed.

For UploadPartCopy:

  • a checksum-enabled MPU first gets an inner hash reader over the logical source range;
  • a range copy hashes only the selected bytes;
  • compression and destination encryption start only after that reader is ready.

The object layer remains authoritative

Early handler preparation does not replace the storage invariant. erasureObjects.PutObjectPart still:

  • re-parses the expected MPU algorithm;
  • requires an effective checksum type that matches;
  • obtains the checksum map after erasure encoding finishes;
  • reports an internal error instead of committing if an enabled algorithm has no result;
  • writes the checksum with the ETag, sizes, and index into part.N.meta, then atomically renames the part.

An internal caller that bypasses the HTTP handler without preparing a valid checksum is therefore rejected just as before. It cannot silently commit a part that violates the MPU invariant.

CopyPart response shape

CopyObjectPartResponse gained the five algorithms supported by this source tree:

ChecksumCRC32
ChecksumCRC32C
ChecksumCRC64NVME
ChecksumSHA1
ChecksumSHA256

All are omitempty, so an MPU without checksums produces the old XML. Normal UploadPart still uses the existing TransferChecksumHeader and echoes only a client request value; fallback computation does not alter that response.

Why it works

After the repair, the data flow is:

logical plaintext part
        |
        +--> client checksum verifier (if supplied)
        |         or
        +--> server-side hasher (if omitted)
        |
        v
compression (optional)
        |
        v
encryption (optional)
        |
        v
erasure encode / storage
        |
        v
persist ETag + size + logical part checksum atomically

This satisfies four requirements that previously appeared to conflict:

  1. Protocol compatibility: omitting an optional header succeeds.
  2. No integrity downgrade: a supplied client value is still checked end to end and is never hidden by server fallback.
  3. Correct object semantics: the checksum covers logical S3 bytes, not compressed data or ciphertext.
  4. Controlled cost: hashing shares the existing read and adds CPU, not a second disk or network pass.

EOF has a precise role. hash.Reader finalizes ServerSideChecksumResult only when it reaches EOF. Closing the compression pipe synchronizes the compressor goroutine with the storage read; the object layer reads the result only after encoding returns. Targeted -race tests verified that concurrency boundary.

The compatibility-baseline blocker

The five new CopyObjectPartResponse fields are exported Go API. SILO’s buildscripts/rebrand-guard rescans imports, environment variables, headers, routes, storage markers, and exported symbols, then compares them in both directions with buildscripts/rebrand-guard/compat-baseline.json. An unacknowledged symbol makes CI fail.

After recording the five #46 fields, the guard still reported two additions:

internal/config/notify:notify:type:LegacyDatabaseTargetError
internal/config/notify:notify:method:LegacyDatabaseTargetError.Error

They did not come from #46. They belong to the earlier database-notification repair f1ba68358 on the local main branch. The cmd startup path intentionally needs the exported type for errors.As, but that earlier commit had not updated the compatibility baseline. Every later change based on that HEAD would therefore fail the CI guard.

We chose “option A”: acknowledge the two notification symbols as part of their original repair while retaining the five #46 fields. The final baseline diff is exactly seven additions and zero deletions, and the guard reports:

exported=9021
Silo rebrand compatibility baseline is unchanged

This does not disable the check. Exact set equality means that acknowledging a nonexistent symbol also fails. The change explicitly records two intentional compatibility-surface additions.

golangci-lint has not yet run locally; it remains a remote go.yml gate. Green local go test, go vet, race, and rebrand-guard results do not substitute for green remote CI.

Verification evidence

The new tests execute 76 subtests across:

  • CRC32, CRC32C, and CRC64NVME FULL_OBJECT;
  • CRC32, SHA1, and SHA256 COMPOSITE;
  • correct client checksums, wrong algorithms, and wrong values;
  • absence of a server-computed checksum in normal UploadPart responses;
  • server values in UploadPartCopy responses and ListParts;
  • a real 5 MiB + 1 KiB two-part full-object merge;
  • zero-length parts and overwriting the same part number;
  • a range copy whose SHA256 covers only the copied interval;
  • single-drive and 16-drive erasure backends;
  • default, versioned, compressed, encrypted, and compressed-plus-encrypted modes;
  • explicit SSE-C and SSE-S3.

Local validation included:

go test -race ./cmd -run '^TestAPIUploadPartServerSideChecksum' -count=1
go test ./cmd -count=1
go test ./... -count=1
go vet ./cmd
git diff --check
go run ./buildscripts/rebrand-guard

All passed. Two subsequent Claude Code Fable 5 Max implementation reviews and the final acceptance review returned GO with no blocking findings.

Cost, risk, and release boundary

When a client omits its value, the server performs one additional hash over the part. CRC cost is small; SHA costs more CPU. Both share the read that already had to occur, without buffering an entire part in memory or adding a completion-time second pass.

During a rolling upgrade, old and new nodes may answer the same checksum-less request differently: a new node accepts it while an old node returns 400. ObjectPartInfo.Checksums did not change format, so stored data remains downgrade-readable, but client-visible behavior stabilizes only after all serving nodes have upgraded. The release note must call that out.

This record describes a local main worktree. The implementation has not been committed, pushed, run through remote CI, or packaged into a release. SILO documentation belongs to silo.pgsty.com; a successful local Hugo build does not mean that the product in the wider pgsty.com ecosystem has shipped.

Why two follow-ups remain separate

Adversarial review found two related but independent issues.

#63: CopyObject + compression

Ordinary CopyObject can also attach a server-side checksum to a transformed stream. It shares the root cause and the new checksumReader mechanism, but it is a different API with a different test matrix and rollback boundary. We chose a separate repair and require that PR to reuse this plaintext-reader contract instead of inventing a second abstraction.

#64: legacy federation

Legacy etcd federation turns UploadPartCopy into an ordinary remote UploadPart. Under the AWS response semantics preserved here, that remote request does not return a server fallback value, so the proxy may still lack the checksum required for CopyPartResult. A follow-up must independently choose between a remote-returned value and an ETag-verified ListParts fallback. It must not make all external UploadPart responses non-compliant merely to simplify an internal proxy.

Separating them does not abandon consistency. Consistency is maintained through one shared rule:

Every server-computed S3 checksum binds to the logical plaintext stream, is installed before any eager transform, and is validated and persisted by the object layer that owns the storage invariant.

Lessons retained

The repair leaves lessons more durable than its individual lines of code:

  1. An optional header does not make internal state optional. If the protocol lets the client omit a value, the server must produce the state its own completion path needs.
  2. Request acceptance and response disclosure are separate contracts. A normal UploadPart may compute internally and still omit the value; UploadPartCopy must return it.
  3. Stream layers are defined by byte semantics. The lowest layer is not automatically correct if it no longer sees logical bytes, and an eager goroutine turns “install later” into a race.
  4. A commercial implementation is evidence, not the specification. AIStor showed the direction and the boundary that could not be copied.
  5. A compatibility guard is a change-acknowledgment mechanism. compat-baseline.json exists to assign every new compatibility surface, not merely to make CI quiet.
  6. Independent defects should ship independently while sharing invariants. #63 and #64 remain separate, but both must cite and obey the checksum-reader contract established here.

The final result is not a broad relaxation. It is a stricter and more accurate boundary: clients may omit optional information; the server may not omit correctness.