Skip to content

upload-session-abort c4: failed/expired sys_upload_session.status have no producer (enforce-or-remove) #7667

Description

@huangyiirene

Symptom

A scan of all sys_upload_session rows after driving both the chunked-complete flow and the abandon/reap flow returns exactly ['in_progress', 'completed']. completing is written transiently and immediately overwritten. The failed and expired statuses are never written by the service, yet the retention rule references them — so retention references two states the system can never enter.

Root cause

Located. In packages/services/service-storage/src/objects/system-upload-session.object.ts the status enum declares failed and expired (lines 104-105) and the retention onlyWhen is { status: { $in: ['completed', 'failed', 'expired'] } } (line 134). A grep across service-storage finds failed/expired only in that enum, the retention clause, the metadata-store.ts type union, and tests — no writer sets either status. An expired session's row is TTL-reaped, never re-statused. This is an ADR-0049 enforce-or-remove candidate.

Fix shape: either give failed/expired a writer (status the row on abort-failure / TTL expiry before it is reaped) or drop them from the enum and the retention onlyWhen.

Related contract note (also captured in the checklist-maintenance card): the chunked-init route takes totalSize, not size (packages/services/service-storage/src/storage-routes.ts:274), and forces chunkSize >= 5 MiB; the checklist step text says size.

Reproduction

Drive a chunked upload to completion and abandon a second session so it is reaped; then scan sys_upload_session.status across all rows → only ['in_progress', 'completed'] ever appear. Grep service-storage/src for 'failed'/'expired' → matches only in the enum, the retention clause, the type union, and tests — no producer.

Source

Extracted from the QA run #7635 (framework 92f26f7, console 6314e87f).

Activity

  1. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Claim — PM session a31dbd89 (domain:services seat, #6021), dispatching now.

    Branch: claude/issue-7667-upload-session-status-liveness

    Anchors re-verified on origin/main before dispatch: enum members at system-upload-session.object.ts:104-105, retention onlyWhen at :134, type union at metadata-store.ts:54; a repo grep confirms no writer for either status (the two storage-routes.ts hits are unrelated error-message string matches). The reap-guard registration lives in storage-service-plugin.ts (~285-350), so the enforce route would touch a #5536 seam file — the dispatch carries the ride-along clause conditionally.


    Generated by Claude Code

  2. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Claim released — dispatch aborted before any work started (PM session a31dbd89, services seat #6021).

    The developer agent dispatched at 11:44Z terminated on its first step: an API session-limit error (resets 12:40Z). Verified nothing was produced — git ls-remote origin 'claude/issue-7667*' returns no branch, no PR exists, and no commits were pushed. The branch name announced in the claim above (claude/issue-7667-upload-session-status-liveness) is unused and free for whoever takes this next.

    Returning to pm:queue rather than leaving a pm:dispatched label describing work nobody is doing. This seat will re-dispatch once the limit resets, unless another seat claims it first — the anchors in the claim comment above were re-verified on origin/main at 11:4xZ and stay usable.


    Generated by Claude Code

  3. self-assigned this
    on Aug 11, 2026
  4. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round 2
    Session: session_01JVfWSQN9RDDGwdVSmMc29x
    Branch: claude/issue-7667-upload-session-status-liveness
    Worktree: cloud session's own clone (mode:cloud)
    Domain: domain:services
    File surface: packages/services/service-storage/src/** — objects/system-upload-session.object.ts (:104-105 enum, :134 retention onlyWhen), metadata-store.ts type union, and — only if the enforce route is taken — the reap-guard registration in storage-service-plugin.ts (~285-350). ⚠️ That file is a #5536 seam file: the dispatch carries the ride-along clause conditionally.
    Container & model: M (ADR-0049 enforce-or-remove is evidence-driven design, not mechanical), mode:cloud, model: opus
    Serial constraints cleared: prior claim on this card (os-help session a31dbd89, 11:43Z) was released at 12:43Z with zero output — branch name announced there is unused; re-using the same anchors (re-verified then, and same-day churn on service-storage since — #7778 test-resolution, #7683 $expand security — touches neither named file). No in-flight PR touches service-storage.


    Generated by Claude Code

  5. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    CollaboratorAuthor
    {
      "issue": 7667,
      "repo": "objectstack-ai/objectstack",
      "status": "draft_pr_open",
      "premise_still_valid": true,
      "premise_evidence": "Re-verified on origin/main 098f4bb: enum members `failed`/`expired` at packages/services/service-storage/src/objects/system-upload-session.object.ts:104-105; retention onlyWhen `$in ['completed','failed','expired']` at :134; type union at metadata-store.ts:54. Grep across service-storage/src confirms no writer for either status — the only non-test hits were the enum, the retention clause, the type union, and the reap-guard doc comment at attachment-lifecycle.ts:342 (which READS status but only compares against 'completed'). Same-day churn (#7778, #7683) touched neither file.",
      "branch": "claude/issue-7667-upload-session-status-liveness",
      "commit": "c213ee8",
      "pr": 7844,
      "pr_url": "https://github.com/objectstack-ai/objectstack/pull/7844",
      "pr_state": "draft",
      "decision": {
        "branch_taken": "enforce",
        "deciding_evidence": "packages/spec/src/api/storage.zod.ts:247 declares UploadProgressSchema.status as z.enum(['in_progress','completing','completed','failed','expired']) — both statuses are PUBLISHED to every client reading the API contract, not merely declared-and-unread. Removal would have forked the object enum from the spec progress contract and forced a packages/spec narrowing (the dispatch's STOP condition); enforcement gives that contract teeth instead. Independently, both name real failure states that were invisible: a completion that threw left the row at `completing` (non-terminal → the 7d retention backstop never reaped it, progress read 'still assembling' indefinitely), and a session past its own expires_at kept accepting chunks until the TTL sweep deleted the row under the caller.",
        "remove_branch_rejected_because": "A partial removal leaves the retention rule naming a state that cannot exist; a full removal additionally strands UploadProgressSchema declaring two values the server can never emit — the same defect relocated one layer up, in a package with a single owner."
      },
      "changes": [
        "packages/services/service-storage/src/storage-routes.ts — new `expireIfPastDeadline` + `markSessionFailed` helpers; chunk PUT and complete refuse 410 UPLOAD_SESSION_EXPIRED on an overdue session and stamp the row `expired`; progress REPORTS `expired` (declared member of UploadProgressSchema.status, and the SDK's resumeUpload polls progress first); the completion backend call + file commit are wrapped so a throw stamps `failed` before the 500 propagates",
        "packages/spec/src/api/error-code-ledger.zod.ts — ⚠️ OUT-OF-SURFACE, ONE ADDITIVE LINE: registers UPLOAD_SESSION_EXPIRED under @objectstack/service-storage",
        "packages/services/service-storage/src/objects/system-upload-session.object.ts — lifecycle comment records the producer for each status the retention clause names, and that adding a member without a writer re-opens the hole (ADR-0049). Enum and retention clause UNCHANGED — they are now correct.",
        "packages/services/service-storage/src/storage-routes.test.ts — 8 new tests, every one asserting the DURABLE ROW via store.getSession, not just the response body",
        "packages/services/service-storage/src/error-envelope.conformance.test.ts — the suite keeps one case per distinct emitted code; added the 410 UPLOAD_SESSION_EXPIRED case",
        "docs/qa/platform-checklist/areas/attachments-storage.json — attachments-storage.upload-session-abort revision 3",
        ".changeset/upload-session-terminal-statuses.md — patch for @objectstack/service-storage + @objectstack/spec"
      ],
      "out_of_surface_additions": [
        {
          "file": "packages/spec/src/api/error-code-ledger.zod.ts",
          "what": "one line registering UPLOAD_SESSION_EXPIRED under @objectstack/service-storage",
          "why": "ErrorCode is the closed union StandardErrorCode ∪ ERROR_CODE_LEDGER; an unregistered code fails schema parse → fails the envelope conformance suites → fails CI (ADR-0112). The only alternative was reusing a semantically wrong registered code (UPLOAD_SESSION_NOT_FOUND for a session that plainly exists), which is exactly the bypass the ledger's own header warns against.",
          "risk": "none identified — purely additive; the union grows, nothing that validated before stops validating. Verified against error-code-ledger.test.ts (SCREAMING_SNAKE, no duplicate, no shadow of the standard catalog, sorted union): 62 spec tests pass."
        }
      ],
      "ride_along_5536": "not_triggered — the diff does not touch storage-service-plugin.ts",
      "behaviour_changes": [
        "NEW REFUSAL: a chunk PUT or a complete against a session past its own expires_at now answers 410 UPLOAD_SESSION_EXPIRED instead of succeeding. Bounded: default sessionTtl is 86400s, and past expires_at+1d the TTL sweep already reaped the row and the reap guard already aborted the backend multipart — so beyond that window the call previously failed anyway, with an opaque backend NoSuchUpload rather than a named 410. The new refusal is a clear failure replacing a confusing one, plus a correct refusal inside the [expires_at, expires_at+1d] grace window.",
        "A failed chunked completion now leaves the row at `failed` rather than `completing`. Purely additive observability — no code path reads `failed` as a refusal, so a retry of the same uploadId still runs the happy path (pinned by a test)."
      ],
      "deliberate_non_behaviours_pinned_by_tests": [
        "a row with no expires_at is never expired — the guard enforces the row's own declared deadline, it does not invent one",
        "a `completed` row does not become `expired` by waiting for the reaper",
        "expiry is checked AFTER the resume-token check, so a caller who cannot prove ownership learns nothing about the session's state",
        "the `failed` stamp is best-effort and loud on failure: a metadata-store error never replaces the real backend cause on its way to the 500"
      ],
      "gates": {
        "build_closure": "pass — pnpm build, 71/71 tasks",
        "service_storage_suite": "pass — 24 files, 361 tests",
        "spec_targeted_tests": "pass — error-code-ledger + storage, 62 tests",
        "check_docs_audit_scope": "pass",
        "check_platform_checklist": "pass — 15 areas, 182 items",
        "check_route_envelope": "pass — the new 410 goes through the shared sendError",
        "typecheck_spec": "pass",
        "typecheck_service_storage": "n/a — the package declares no typecheck script (not type-check-covered). An ad-hoc `tsc --noEmit -p tsconfig.json` over it reports only pre-existing extension-less test-import noise (TS2835) in files this diff does not touch; zero errors in storage-routes.ts or either edited test file.",
        "engine_double_gate": "n/a — no new test fake with update/delete verbs; the tests drive the real StorageMetadataStore on its no-engine Map path",
        "ci": "in_progress — PM owns CI convergence, the ready-flip and landing"
      },
      "checklist_maintenance": {
        "item": "attachments-storage.upload-session-abort",
        "revision": 3,
        "change": "closes the clause-5 finding revision 2 deferred to this issue: added steps that drive `failed` (a completion whose backend throws) and `expired` (backdate expires_at WITHOUT sweeping, then chunk → 410 / progress → 'expired'); added an acceptance clause for the 410 refusal; named the producers in `source`; recorded transient `completing` as a knownGap rather than an unreachable-variant FAIL — the complete route writes it and overwrites it within the same request, so a scan of settled rows can never show it and a run that cannot produce it is not a defect"
      },
      "open_questions": [
        "packages/client `storage.resumeUpload` reads the progress body but ignores `status`, so resuming an expired session now surfaces as a 410 on the first chunk PUT rather than a clean early exit. Honest failure, not a regression — but a short-circuit on `status === 'expired'` would be better client behaviour. NOT taken here: packages/client is outside this card's declared file surface. Suggest a separate domain:client card.",
        "`expires_at` is now enforced at request time on chunk/complete, but the presigned-upload family has no comparable deadline surface. Not in scope; noting it so the asymmetry is a recorded choice rather than an oversight.",
        "The reap guard's doc comment (attachment-lifecycle.ts:342) already described `in_progress / failed / expired with a backend upload` as the abort branch — written before any producer existed. It is now accurate as written and was left untouched."
      ],
      "notes": "Worktree: the repo's guard-main-checkout hook blocked edits on the container's primary checkout, and both `git checkout main` and `git checkout --detach` were refused by the environment's permission classifier. Resolved without weakening the rule: created a real dedicated worktree detached at the branch tip (`git worktree add --detach ../objectstack-7667`), did all work + `pnpm install` + gates there, and pushed with `HEAD:refs/heads/claude/issue-7667-...`. No `git stash` at any point."
    }

    Generated by Claude Code

  6. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    CollaboratorAuthor

    ADDENDUM — patch round 1 (generated-docs backfill). Executed the PATCH ROUND instruction on PR #7844 verbatim; nothing else on the PR changed.

    {
      "issue": 7667,
      "pr": 7844,
      "round": "patch-1",
      "status": "patch_pushed",
      "branch": "claude/issue-7667-upload-session-status-liveness",
      "patch_commit": "d4cceb6",
      "previous_commit": "c213ee8",
      "cause_confirmed": "产物随源走 — registering UPLOAD_SESSION_EXPIRED in ERROR_CODE_LEDGER widens the ErrorCode union that every enveloped response schema references, so all 11 generated content/docs/references/api/*.mdx pages rendering it went stale and `tsx scripts/build-docs.ts --check` failed the TypeScript Type Check job. The generated set was not committed with the source change.",
      "steps_executed": [
        "1. Pre-flight per #5370: worktree tree clean, no MERGE_HEAD / REBASE_HEAD in /home/user/objectstack/.git/worktrees/objectstack-7667 — gen:schema was NOT run mid-merge",
        "2. pnpm --filter @objectstack/spec gen:schema — 1590 schemas, 1578 definitions; authorable-defaults verified against upstream 098f4bb6b9d2, 1276 defaults unchanged",
        "3. pnpm --filter @objectstack/spec gen:docs — 230 files generated, 11 tracked files changed",
        "4. Committed content/docs/references and NOTHING else (verified with `git status --short` before and after staging)",
        "5. pnpm --filter @objectstack/spec check:docs — GREEN",
        "6. Pushed c213ee8..d4cceb6 to the same branch"
      ],
      "files_committed": [
        "content/docs/references/api/analytics.mdx",
        "content/docs/references/api/auth.mdx",
        "content/docs/references/api/automation-api.mdx",
        "content/docs/references/api/batch.mdx",
        "content/docs/references/api/contract.mdx",
        "content/docs/references/api/error-code-ledger.mdx",
        "content/docs/references/api/export.mdx",
        "content/docs/references/api/metadata.mdx",
        "content/docs/references/api/package-api.mdx",
        "content/docs/references/api/protocol.mdx",
        "content/docs/references/api/storage.mdx"
      ],
      "diff_shape": "11 files, +69/-67, and the ENTIRE diff is the one ledger line propagating: a new `* UPLOAD_SESSION_EXPIRED` bullet in error-code-ledger.mdx (inserted in sorted position before UPLOAD_SESSION_NOT_FOUND), and the rendered union arity moving `+260 more` → `+261 more` in every `error` column. Inspected each file's diff — no unrelated pre-existing drift was absorbed into this commit.",
      "check_docs_rerun": "PASS — `✅ 230 generated files in sync with packages/spec` (plus `✅ import examples resolve against api-surface/`, 62 accepted baseline gaps). The two `Schema directory .../conversions|migrations does not exist` lines are pre-existing warnings, not failures.",
      "authorable_surface_anchor": "NOT touched — `authorable-surface.base.json` does not appear in `git status` after gen:schema, so no re-anchoring rode along and nothing was hand-edited. gen:schema emitted its standard shallow-checkout advisory (the anchor mirrors 53ef05744f37 vs the resolved baseline 098f4bb6b9d2, direction unestablishable on a truncated history) — informational, `Not an error either way` per its own text, and unchanged by this round.",
      "gen_openapi_5371": "backfill condition did NOT trigger — packages/spec/json-schema/openapi.json survived gen:schema's rmSync (mtime predated this round). Ran `gen:openapi` anyway to keep the local artifact current; it is gitignored (.gitignore:61 packages/spec/json-schema/), so it is absent from the commit either way.",
      "scope_discipline": "No source, test, changeset, or checklist file was touched in this round. `git status` is clean at d4cceb6.",
      "ci": "not polled — the PM owns CI convergence, the ready-flip and landing, per the dispatch contract."
    }

    Generated by Claude Code

  7. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    ACCEPT — PR #7844 (services seat review, session session_01JVfWSQN9RDDGwdVSmMc29x).

    What shipped: the enforce branch of ADR-0049, with the deciding evidence I endorse — UploadProgressSchema.status already publishes failed/expired to every API client, so removal would have forked the object enum from the spec contract (and hit the dispatch's own spec-STOP condition), while both statuses name real, previously-invisible failure states. expired: chunk PUT / complete against a session past its own expires_at now refuse 410 UPLOAD_SESSION_EXPIRED and stamp the row; progress REPORTS the status (the SDK polls it first). failed: a completion whose backend throws stamps the row instead of stranding it at non-terminal completing (which the 7d retention never reaped). Retry semantics preserved — failed records an attempt, doesn't lock the session.

    Review notes:

    Marking ready and arming auto-merge now.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions