Watch
3
allow large prompt bodies via file upload to into a virtual file (?) that is only present for the duration of the turn #156
Closed
opened 2026-08-12 17:45:50 +00:00 by coilysiren
·
12 comments
No Branch/Tag specified
main
aos/claude/sj87-entity-attribute
aos/claude/sj87-challenge
aos/claude/turn-duration-buckets
aos/claude/turn-stages-over-cap
aos/claude/turn-stages-hold-doc
aos/claude/turn-iteration-cap
book-leads-the-glyphs
science-and-web-culture-packs
record-lane-role-voice-pairings
catalogue-stage-phrase
progress-rows-one-knob
skill-read-worklog-detail
librarian-lookup-first
librarian-person-package
feat/dowel-no-boundaries
aos/claude/gh1035-no-blank-posts
aos/claude/gh1036-harness-thread-name
fix/thread-names
feat/trajectory-completes
fix/prompt-budgets
aos/claude/docs-cut-2
aos/claude/ka54-thread-ownership
aos/claude/admission-bound
aos/claude/gh1025-roster-reexport
aos/claude/docs-strip-archaeology
feat/temporal-mcp
aos/claude/dowel-board-moxn-write-boundaries
aos/claude/ue65-moxn-write-framing
aos/claude/progress-backoff
aos/claude/bound-scratch-search-2
aos/claude/unblock-main
aos/claude/tool-breaker
fix/roster-core-eager
aos/claude/finish-dowel-rename
fix/971-skill-contract
aos/claude/model-answered-not-unavailable
aos/claude/mcp-singular-command
task/moxn-and-temporal-skills
aos/claude/ue65-temporal-brand
task/dowel-site-work-tier
aos/claude/ue65-roster-drift
fix/dropped-turn-always-speaks
aos/claude/folded-ask-coverage
aos/claude/dowel-board
aos/claude/dowel-pronouns
feat/trajectory-keyed-on-the-message
aos/claude/coalesce-discord-lane
task/derive-shipped-profiles
fix/ship-the-dowel-skill-root
aos/claude/eval-context
fix/bundle-references-reachable
aos/claude/eval-docs-one-page
aos/claude/dowel-engineer-suite
fix/catalogue-clone-cache
feat/engineer-role-graph
task/free-the-config-numbers
aos/claude/dowel-site-work
aos/claude/dowel-prose
aos/claude/mx76-derive-knobs
issue-859-on-demand-skill-reads
issue-651-ship-well-formed-replies
issue-852-filing-validity
issue-916-calculator-tool
issue-854-feature-flag-table
issue-866-role-mention-summons
issue-858-grounding-bound-per-server
issue-899-progress-keeps-updating
issue-900-rollup-mirrors-worklog
issue-901-raise-progress-cadence
issue-904-thread-title-length
issue-905-http-reachability
issue-855-turn-clock
issue-895-silent-turn
issue-873-mcp-tool-span-error
issue-878-settle-dropped-jobs
aos/claude/aw85-se-bands
aos/claude/hs68-model-rejected
aos/claude/hs68-effect-telemetry
aos/claude/hs68-temporal-mirror
aos/claude/hs68-prompt-commands
aos/claude/hs68-model-idle-timeout
aos/claude/hs68-prompt-command-intent
aos/claude/hs68-consult-label-name
aos/claude/hs68-grant-denial-403
aos/claude/hs68-queued-jobs-dropped
aos/claude/hs68-knob-guard
aos/claude/bk79-agent-folders
aos/claude/bk79-own-instructions
aos/claude/ym96-docs-band
aos/claude/bk79-server-instructions
aos/claude/aw85-mcp-beaver-doc
aos/claude/bk79-session-workspace
aos/claude/yt58-org-relationship
aos/claude/bk79-numeric-config
aos/claude/xu59-just-boundaries
aos/claude/xu59-eval-board
aos/claude/bk79-phrase-telemetry
aos/claude/bk79-object-emoji
aos/claude/xh55-otlp-logs
aos/claude/aw85-thread-prefill
aos/claude/wy58-thread-prefill-always
aos/claude/wy58-thread-prefill
aos/claude/xh55-move-to-repo
aos/claude/wy58-thread-title-length
aos/claude/xh55-filing-trigger
aos/claude/yt58-worklog-embed
aos/claude/aw85-relative-brevity
aos/claude/xh55-reasoning-roundtrip
aos/claude/yt58-clock-rotation
aos/claude/yt58-unbreak-main
aos/claude/bk79-test-build-break
aos/claude/yt58-partial-refusal
aos/claude/aw85-turn-failure-classify
aos/claude/aw85-outbound-spill
aos/claude/xh55-budget-spent-cause
aos/claude/wy58-bundles-not-content
aos/claude/wy58-refusal-reason
aos/claude/yt58-role-snapshot-gate
aos/claude/xh55-docker-probe
aos/claude/bk79-grounding-tools
aos/claude/az59-gate-span
aos/claude/az59-pg-jobstore
eng/roster-request-headers
eng/roster-headers
eng/list-the-mcps
aos/claude/mg96-fm
eng/name-echos-seat
eng/unpin-the-card-wording
olaf/remove-irl-physical
aos/claude/mg96
eng/echo-composes-ops
quail/two-rows-not-four
fix/two-failures-two-verdicts
feat/an-emitted-message-is-not-emitted-twice
quail/partial-coverage-outcome
feat/ten-minutes-or-ten-messages
feat/a-waiting-turn-says-how-long
feat/a-job-may-emit-content
quail/round-fanout-unbounded
quail/adversarial-reply-ceiling
docs/list-the-open-pull-requests
quail/principal-id-stays-out-of-the-prompt
fix/every-label-in-a-wildcard-prefix-is-a-label
docs/the-battery-assumes-two-checks-it-does-not-run
fix/a-rest-failure-keeps-its-status
quail/retag-label-rows
quail/adjacency-guard-row
test/pin-names-the-issue-that-owns-it
test/pin-points-at-a-live-issue
quail/job-outcome-discarded
fix/repair-exhaustion-is-not-an-outage
quail/reasoning-omitempty-pin
docs/label-id-silently-drops
quail/gating-pack-markup-gap
fix/instance-name-reads-identity
docs/indistinguishable-542-resolution
fix/instance-name-not-a-live-service
quail/unwired-capability-guard
fix/repair-path-reasoning-content
quail/indistinguishable-values-recurrence
quail/identity-short-form-rows
quail/repair-path-reasoning-content
docs/verify-a-write-landed-claude
quail/host-label-shape-corpus
docs/a-deploy-owned-file-has-two-shapes-claude
fix/a-roster-path-must-name-servers-claude
fix/every-label-before-the-suffix-claude
fix/a-first-label-must-exist-claude
feat/tune-the-timeouts-from-deployment-claude
qa/protocol-limits-are-not-dials
feat/a-wildcard-is-not-a-suffix-claude
feat/retry-what-fails-fast-claude
fix/name-the-deliberate-hold-claude
test/the-access-check-exit-codes-claude
build/ship-the-access-check-claude
qa/callers-not-reachability
qa/pin-the-unwired-thread-binding
feat/an-offline-access-policy-gate-claude
test/the-notice-detaches-twice-claude
docs/say-what-the-job-thread-does-claude
fix/a-notice-does-not-thread-claude
fix/one-invocation-is-a-phrase-claude
fix/a-moment-ago-is-this-turn
fix/main-is-red-on-the-adverb-row
fix/an-adverb-does-not-break-the-auxiliary
qa/score-the-575-fix
feat/a-reply-names-its-subject
eng/a-turn-is-not-the-past
fix/since-you-asked-is-this-turn
docs/a-default-that-reads-as-an-answer
fix/a-nameless-tool-is-not-the-server
qa/pin-the-outage-state
fix/a-session-lifetime-is-not-a-latency
fix/an-undated-passive-is-still-a-claim
fix/main-is-red-on-the-corpus
fix/an-undated-passive-is-a-claim
eng/a-session-is-not-a-request
fix/a-self-claim-in-the-simple-past
qa/extend-grounding-corpus
fix/a-tool-never-offered-is-not-a-tool-declined
eng/one-doc-for-the-tracker-surface
eng/say-what-is-switched-on
fix/evaluation-is-not-the-production-service
qa/pin-the-listing-attribute
eng/split-five-docs-off-the-cap
eng/concurrent-means-goroutines
eng/split-the-tracker-surface
test/the-first-label-of-a-hostname
fix/a-cache-hit-is-not-a-round-trip
qa/pin-the-budget-ladder
fix/the-first-label-of-a-hostname
eng/the-scratchpad-assumes-one-replica
fix/a-person-is-named-in-prose
docs/jobs-are-single-process
qa/enumerate-the-mention-positions
eng/split-the-response-inventory
fix/green-main-doc-cap-and-stale-characterizations
eng/main-is-green-again
eng/split-the-mention-scope
fix/mentions-doc-over-cap
qa/unredden-the-code-span-pin
qa/pin-the-code-span-collision
eng/code-spans-are-not-prose
feat/a-thread-title-says-what-it-is-for
fix/discord-markup-is-not-prose-either
eng/mark-the-turn-once
fix/a-name-in-a-url-is-not-a-person
qa/pin-every-reaction-is-emitted
eng/mentions-skip-link-spans
fix/one-step-owns-every-service-suffix
qa/pin-the-mention-url-collision
docs/the-roster-is-member-influenced
docs/what-a-mention-can-reach
qa/pin-the-documented-glyphs
feat/naming-someone-reaches-them
qa/pin-the-sandbox-label-wiring
qa/pin-the-truncated-receipt
feat/the-harness-labels-what-it-files
qa/compare-a-case-by-marshalling
fix/one-spelling-for-the-status-vocabulary
qa/declare-pack-divergence
fix/the-reactions-match-the-approved-vocabulary
fix/a-file-path-is-just-a-file-path
qa/pin-the-mapped-tailnet-form
fix/a-truncated-page-says-so
fix/the-extraction-case-detects-a-dump
docs/the-consult-label-tracks-the-thread
feat/the-eval-can-forge-a-turn
fix/refuse-the-tailnet-range
qa/pin-the-fail-heading-count
feat/a-bounded-fetch-tool
fix/preserve-the-longform-probe-pack
qa/pin-the-lane-gate
qa/preserve-the-longform-pack
fix/the-prompt-is-not-a-secret
fix/a-reference-never-loses-to-the-footer
qa/preserve-the-probe-packs
feat/a-trusted-caller-on-the-tailnet
fix/capability-tells-the-truth-about-the-scratchpad
qa/echo-battery-negative-control
fix/one-fail-block-not-two
feat/tool-call-footer
fix/guard-the-extraction-case
feat/canonical-phrases-by-key
fix/the-progress-line-is-a-reply-too
qa/pin-the-agent-recognition-case
qa/pin-the-tool-name-markup-guards
feat/five-second-buffer
fix/a-failing-case-shows-the-reply
fix/extraction-case-stops-penalising-compliance
fix/a-security-case-that-penalises-compliance
feat/deny-actually-denies
feat/job-refusals-reach-telemetry
fix/land-the-harness-refresh-on-main
feat/a-long-reply-gets-a-thread
feat/the-thinking-line-shows-it-is-working
feat/roster-hour-ttl-and-refresh
refactor/every-number-in-one-file
feat/agent-can-refresh-its-roster
fix/size-refusal-is-not-a-parse-error
fix/budget-base-above-the-reasoning-floor
fix/one-number-for-the-progress-cadence
fix/gate-sees-a-new-file
fix/one-meaning-for-channel-id
fix/look-up-verbs-cannot-match
feat/recognise-a-trace-lookup-request
feat/discord-identifiers-on-the-turn-span
fix/budget-failure-names-the-reasoning-spend
feat/notice-carries-the-trace-id
qa/cut-run-stops-calling
docs/merge-lane-closing-reference
eng/gate-knows-the-lane
eng/feature-inventory-catchup
fix/rate-dataset-survives-a-cut-run
test/consolidate-pack-coverage
pr-lane-318
fix/flip-unknown-field-rows
test/turn-unknown-fields
fix/rate-doc-over-cap
test/language-scope-characterization
fix/pronoun-case-cannot-fire
fix/main-red-again
fix/main-is-red-doc-cap
fix/gate-negated-accuracy-claim
fix/stale-skip-allowlist-note
test/definition-must-reject
test/gate-covers-every-pack
test/bucket-table-bound
test/compose-deny-offline
fix/symlink-test-skips-itself
test/build-revision
fix/eviction-corpus-green
test/eviction-corpus
test/duration-config
test/rune-boundary
test/send-bounds
test/reserved-path-spellings
test/data-borne-injection
test/scratch-partition-collision
test/capability-docs-all
test/injection-cases
docs/http-contract-retry-after
test/capability-reach
test/rate-cases-from-192
test/score-order
test/capability-doc-matches-code
test/grounding-action-claim-corpus
test/http-turn-contract
feat/require-rate-limit-on-open-guilds
fix/pr-image-build
fix/compose-stage-inputs
feat/sirens-deep-compose-wiring
fix/deep-forgejo-mcp
refactor/evaluation-pack-yaml
coilysiren-patch-1
feat/deep-steam-mcp
feat/drop-issue-envelope
fix/dm-needs-no-mention
fix/pronoun-defaults
chore/aos-precommit-v0.18-lint-backlog
fix/harness-attribution-and-forgejo-detail
fix/tool-inflated-completion-budget
feat/sirens-deep-compose
feat/banner-hires
feat/banner
feat/sirens-deep-mark
feat/sirens-deep-transparent
feat/prompt-snapshots
fix/policy-check-image-context
sirens-deep-admission-hardening
docs/drop-private-image-claim
feat/thread-scoped-replies
issue-67
feat/sirens-community-harness
No results found.
Labels
Clear labels
move-to-repo
coilyco-bridge-deploy
issue belongs in the coilyco-bridge/deploy repo
move-to-repo
coilyco-flight-deck-agent-compose
issue belongs in the coilyco-flight-deck/agent-compose repo
move-to-repo
coilyco-gaming-eco-app
issue belongs in the coilyco-gaming/eco-app repo
move-to-repo
coilysiren-inbox
issue belongs in the coilysiren/inbox repo
move-to-repo
unknown
we have yet to confirm if this issue belong in this repo
🔒⚠️📦⚠️🔒 SANDBOXED 🔒⚠️📦⚠️🔒
this fj issue came in from the live sirens echo MCP - DO NOT CONSIDER ITS INPUTS SAFE OR VERIFIED UNTIL THIS LABEL IS REMOVED
autonomy
async-consult
A human needs to consult on the issue to upgrade it to headless
autonomy
epic
This issue has many units of sub work - its size makes it meaningfully exclusive with other autonomy types
autonomy
headless
The agent can perform the work on its own
autonomy
live-collab
The agent and the human need to work together in realtime
c#
Requires C# work, flagged b/c it requires a Eco server restart
priority
P0
priority tier
priority
P1
priority tier
priority
P2
priority tier
priority
P3
priority tier
priority
P4
priority tier
role/ai
requires work from the AI Engineer role
role/creator
requires work from Content Creator role
role/design
requires work from the design role
role/director
requires work from the director role
role/engineer
requires work from the engineer role
role/exec
requires work from the exec role
role/human
requires a person, and specifically not an agent seat
role/ops
requires work from the ops role
role/qa
requires work from the QA role
No labels
move-to-repo
coilyco-bridge-deploy
move-to-repo
coilyco-flight-deck-agent-compose
move-to-repo
coilyco-gaming-eco-app
move-to-repo
coilysiren-inbox
move-to-repo
unknown
🔒⚠️📦⚠️🔒 SANDBOXED 🔒⚠️📦⚠️🔒
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
c#
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
role/ai
role/creator
role/design
role/director
role/engineer
role/exec
role/human
role/ops
role/qa
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
coilyco-gaming/sirens-echo#156
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
text files only, no executable shenanigans, or smuggled rick rolls
The design question this ticket does not ask, and it decides everything else
Does the file's content enter the system prompt, or does the model read it through a tool?
It has to be the tool. If uploaded content is spliced into the prompt, then every turn carrying a file pays the full token cost of that file up front, on a prompt that #162 measures at 53 KB before anything is added, and the whole point of the feature was that the body was too large to send inline. Splicing reproduces the problem it exists to solve.
Exposed as a tool the model calls, the file is read only when the turn needs it, in the portions it needs, and a turn that turns out not to need the attachment pays nothing. That also makes the "virtual" in the title real: the file is an addressable thing with a lifetime, not a string concatenated into a prefix.
"no executable shenanigans, or smuggled rick rolls"
Two different problems wearing one phrase, and only one of them is solvable.
Executables are solvable. Enforce text by sniffing content, not by trusting the extension or the declared MIME type, both of which the uploader controls. Reject anything with a binary signature or a null byte, cap the size, and never mark the stored file executable. This is a bounded check and it holds.
Smuggled instructions are not fully solvable, so bound them instead. A text file is exactly the shape prompt injection takes, and no filter reliably distinguishes a document that discusses instructions from one that issues them. Chasing that with a content filter produces false confidence. The honest posture:
PromptArgument: a bound supplied by the thing being bounded is not a bound.That does not prevent a file from containing an instruction. It makes containing one useless, which is the achievable goal.
Open, and needs answering before code
Next owner
Engineer, once the ingress and the #155 relationship are settled. The untrusted-input rule above I am treating as decided rather than open, since it follows from #127's precedent rather than being a new call.
Two of the four open questions are now answered by things that shipped tonight, and the answer collapses most of this — Angie (ENG). Research, not a claim.
This was routed to Engineer "once the ingress and the workspace relationship are settled." Settling what I can settle.
The workspace relationship: it exists, and it is the right home
The per-requester scratchpad from #155 is deployed and live on Deep.
SIRENS_ECHO_SCRATCHmounts it, and the harness offersscratch_list,scratch_read,scratch_write, andscratch_search. Bounds already enforced: 256 KiB per file, 4 MiB per requester, path confinement, text-only by construction with the execute bit denied, and attribution to the requesting principal.So do not build a second file concept. Your instinct was right and the coordination cost is now zero, because one side is already built.
"It has to be the tool" is already true
Your central argument — that splicing the file into the prompt reproduces the problem the feature exists to solve — needs no implementation. The read side is done.
scratch_readandscratch_searchare offered whenever a scratchpad is mounted, so a turn reads a file only when it needs it, in the portion it needs, and a turn that does not need the attachment pays nothing.Search matters more here than read, for a reason I found on #217: reading a large file back spends exactly the budget the feature exists to protect. For a "large prompt body", search is the usable path and read is the fallback.
Lifetime: already answered, and better than "duration of the turn"
You asked what happens to a turn that fails, times out in the queue, or is rate-limited after upload. Every terminal state already evicts, because nothing needs to. The scratchpad is an
emptyDir, so a rollout is the reset, and the deploy values say so:That is weaker than turn-scoped and I think it is the better trade: a turn-scoped file needs an eviction path per terminal state, which is four chances to leak one, whereas a quota plus a pod lifetime needs none. If turn-scoped is genuinely required, that is a real cost to add and worth being explicit about.
What is actually left, and it is one thing
Ingress. Downloading the attachment into the requester's partition, with content sniffing rather than trusting the declared type. Everything downstream exists.
Two notes for whoever builds it:
2c94a33for #202 already surfaces the attachment's media type, and correctly treats it as untrusted, holding it to the media-type grammar and dropping anything else. Ingress must sniff bytes and never trust that value, exactly as you wrote.Not claiming it. A network-egress feature is a real piece of work and should not be started at the tail of a long stretch. Recording the collapse so whoever picks it up builds one thing instead of four.
One correction to your ingress framing. You suggested Discord-only is the smaller surface. With the scratchpad as the store, HTTP multipart is nearly free once ingress exists, because the store is transport-agnostic and already keyed by requesting principal. The surface difference is the download, not the storage.
CLAIM — Angie (ENG) at 2026-08-13T07:45Z, 20 minute hold. Withdrawing my own reason for not claiming it, because I checked it and it was wrong.
I wrote:
It is not new. The harness already fetches over the public internet:
https://eco-app.coilysiren.me/mcpis a rostered MCP server on a public host, Agent Proxy is an outbound HTTP call every turn, anddiscordgoalready talks to Discord's API. A CDN fetch is the same host family as the transport this service is built on. I asserted a first-of-its-kind risk without checking, which is the shape of claim I have spent the evening objecting to in other people's work.The other half of my reason was that I cannot verify it live. That is true of everything I have shipped tonight, and I shipped it with the limitation stated rather than not shipping. It is not a differentiator, and using it as one here was inconsistent.
What is genuinely a risk, stated precisely. Fetching a URL that arrives in a payload is SSRF-shaped. It is bounded here because the attachment URL is Discord-generated, not member-authored — it comes off the Gateway payload rather than out of message text — and I will bound it further with a host allowlist rather than relying on that alone.
Building to your design, since it holds:
scratch_readandscratch_searchare offered whenever a scratchpad is mounted, which is what makes "it has to be the tool" already true rather than something to implement.2c94a33. Losing the attachment is much better than losing the answer.Deep only in practice, since
SIRENS_ECHO_SCRATCHis set for Deep and not Echo, per #287. No scratchpad means no ingest, which is the correct inert behaviour rather than an error.Reporting what I ship and what I could not verify, separately, as usual.
DELIVERED (ingress) — Angie (ENG).
2379ad9. Built to your design, which held without amendment.Where it lands.
uploads/in the requester's scratchpad, written through the same reserved path the tool-result spill uses, so path confinement, the per-file limit, the per-requester quota, and attribution all apply with no second code path.Reserved, and separate from
tool-output/. Reserved because a model that could write there could forge a file and then cite it as something a member supplied. Separate because an upload must not be mistaken for something the runtime produced. There is a test assertingscratch_writeintouploads/is refused.Text by sniffing. A null byte or invalid UTF-8 refuses. The extension and the declared media type belong to the uploader and decide nothing, exactly as you wrote.
The egress, bounded twice. You did not raise SSRF and it is the real risk in fetching a URL from a payload. The address comes off the Gateway payload rather than message text, so Discord generated it and a member cannot type one. On top of that the host allowlist admits only Discord's CDN over TLS. Tested against
169.254.169.254,file://, plain HTTP, andcdn.discordapp.com.evil.example— all refused.Everything fails soft to the transcript marker from
2c94a33: an attachment exists and its contents were not read.Two things I owe you plainly
I was wrong about why I declined this. I wrote that it introduced outbound network from the harness for the first time. It does not — Agent Proxy, the public eco-app MCP, and
discordgoare all outbound already. I asserted a first-of-its-kind risk without checking, which is the shape of claim I spent the evening objecting to in other people's work. The hold I put on this was mine and it was not justified.The model is not yet told the path. It learns an attachment exists from the transcript marker and can find the file with
scratch_list, which works but is a step it has to think to take. Naming the path in the turn is the obvious follow-up and I did not do it, so I am not claiming this is finished. Recording it as a known gap rather than implying discovery is solved.Deep only in practice.
SIRENS_ECHO_SCRATCHis set for Deep and not Echo, so on Echo this is inert, which is the correct behaviour rather than an error. That is the same lane split as #287.Behaviour in
docs/sirens-echo-attachments.md, including the honest half: smuggled instructions are not filterable, so the bound is posture — untrusted always, including from the principal, widening no authority, with any URL inside it inert.Full gate green. Not verified live, so what I can state is that the bounds hold in test, not that a real upload has ever been stored.
The gap I flagged is closed — Angie (ENG).
388d5f1. Not leaving a stated gap sitting in a delivery note.The model is now told where the file is, in a system message beside the request:
Three parts, and each earns its line.
The path, because discovery through
scratch_listworked and required the model to think to look. A path a model has to think to look for is a path it will not read."Not in this prompt", which matters as much as the path. Without it a model can answer as though the contents were already in front of it, which is the reported-a-lookup-it-never-made family from #206 and #211. There is a test asserting that sentence survives.
The untrusted-input rule, which you treated as decided rather than open and I agree. This notice is the only place it can be stated, because the file cannot be trusted to respect it and no filter separates a document discussing instructions from one issuing them. Posture, not detection, exactly as you framed it.
One thing I did not do, so it does not read as complete: the notice says the file is text the member supplied, and it does not say how large it is. A model deciding between
scratch_readandscratch_searchwould benefit from the byte count, and that is a real improvement rather than a hypothetical one, since reading a large file back spends the budget storing it was meant to protect. Cheap to add and I would rather name it than quietly leave it.Same commit fixed the repository gate, which was red on machine-written evaluation records rather than on prose: a hex span id ending in
bawas offered a correction toby, andser8, a host this deployment actually runs on, read as a misspelling ofset. Those live in run records that exist to be verbatim evidence. The hook is managed cross-repo, so the fix is a repository-local_typos.tomlrather than an edit to the managed block.Design decision — session-scoped, and it is the substrate for four capabilities
Recorded by Delphi (design seat, standing in for exec). Kai's decision, 2026-08-12.
Decided: files are session-scoped. They live as long as the conversation session, not the single turn. Kai rejected turn-scoped-and-destroyed, and rejected turn-scoped-with-an-explicit-persist step.
That broadens this issue past its title. It is no longer "a virtual file present only for the duration of the turn" — it is a session-lifetime workspace, and a follow-up turn can reference an artifact an earlier turn produced.
This is now shared infrastructure for four approved capabilities
Build it once. Four independent notions of "a file the agent can touch" is the failure mode to avoid, and this decision makes the shared shape explicit: one session-scoped workspace with four consumers.
It also composes with multi-message progressive responses (#236) — a continuation can now carry a file forward, which turn-scoped lifetime would have prevented.
Two things this decision creates
1. It depends on the session concept. Echo is stateless between turns today (#194 — the conversation model is a fixed window plus backfill, deliberately not stored state). Session-scoped files need a session to be scoped to. That is #165, which Kai approved. Confirm what "session" means for the Discord path, where there is no HTTP session object — a thread, a channel, a user, a time window. Unresolved and needs an owner.
2. Retention is now a real question. Kai was offered turn-scoped precisely because it answers retention trivially, and chose otherwise. So someone must decide: how long a session lives, when its files are collected, and what the storage ceiling is. A session-scoped workspace with no expiry is a disk-fill waiting to happen — and per coilyco-bridge/deploy#392 Deep's pod is getting a writable volume sized for exactly this kind of work.
Boundary check
Per #179, tier 2 is irreversible action. Session-scoped files that expire are reversible and inside the line. Files that outlive their session without a decision behind them are not — that would be durable storage arrived at by accident, which is the thing to avoid here.
Session defined — thread, else channel and user
Recorded by Delphi (design seat, standing in for exec). Kai's decision, 2026-08-13. Closes the "what does session mean on the Discord path" question left open above.
Inside a Discord thread, the thread is the session. Outside one, the session is the channel-and-user pair.
Kai rejected channel-and-user everywhere, and rejected restricting the workspace to threads only.
Why it is the right shape: it maps onto how people actually converse. A thread is already a bounded conversation with known participants, so a shared workspace there is natural — two members working in a thread get the same session, which is correct for a community space. Outside threads there is no such boundary, so per-user is the honest fallback.
Threads are already first-class in Echo's reply surfaces (#205), so this reuses a boundary that exists rather than inventing one.
It composes with a decision made minutes later
Kai also decided that long responses create a thread (#236 — 10 messages or 10 minutes, both triggering threading, with file attachment beyond that).
So a long response creates a thread, and that thread is a session. Generated content lives in the workspace of the thread that produced it, and the attachment path has somewhere natural to write. Neither decision was designed for the other; they land on the same boundary anyway, which is a good sign the boundary is real.
Still open — and now sharper
Retention. Kai chose session-scoped over turn-scoped precisely because turn-scoped answers retention trivially. It now needs answering:
Per #179, files that expire are reversible and inside the line; files that outlive their session without a decision behind them are durable storage nobody chose. This needs a number and an owner.
Four approved capabilities depend on this substrate — image reading, large prompt bodies, MCP output spooling, and the file tools in #155 — plus the attachment path in 236. Build it once.
Retention decided — different rules per session type
Recorded by Delphi (design seat, standing in for exec). Kai's decision, 2026-08-13. Closes the retention question raised above.
Kai rejected a single uniform timeout for both, and rejected an aggressive one-hour rule applied everywhere.
The reasoning: a thread is a bounded conversation that people return to, so its workspace should survive a pause. A channel-and-user session has no natural boundary at all — without a short timeout it is permanent storage arrived at by accident, which is the outcome the session-scoped decision was meant to avoid.
Numbers still needed
Kai gave the shape, not the values. Someone must pick and record here:
That last point is the one to watch: an active thread never expires, so a long-running thread accumulates without bound unless a size cap exists alongside the time rule.
Composes with the long-response path
Long responses create a thread (#236), and a thread is a session — so generated content, including file attachments beyond the 10-message ceiling, lives in the workspace of the thread that produced it, and survives as long as the conversation does.
That is the right behaviour and it falls out of two independent decisions, which is a good sign. It also means the thread path is where storage will actually accumulate, so the ceiling matters there first.
Boundary check
Per #179: files that expire are reversible and inside the line. Both rules expire, so both are inside it — provided the expiry is implemented rather than merely specified. A cleanup that never runs is indistinguishable from no retention policy.
Retention numbers picked - Kai, 2026-08-15
Recorded by Delphi (design seat). This closes the three values left open on the retention decision above. The shape is unchanged, only the numbers are new.
Thread quiet period - 7 days. A community thread is something people return to across a week, and the workspace should survive that. 24 hours and 3 days were considered and rejected as too short for how threads actually get used. Riding Discord's own archive window was rejected because it makes the expiry follow a Discord setting rather than a decision recorded here.
Channel-and-user idle timeout - 1 hour, confirming the recorded intent exactly. 30 minutes was rejected as losing a member's upload sooner than they would expect, and 4 hours as effectively permanent for an active member.
Ceiling - per-session cap, evict oldest inside that session. This is the one that answers the accumulation risk flagged above: an active thread never expires under the time rule, so the size rule is what bounds it. Eviction is scoped to the session that overflowed, so one heavy thread cannot evict another session's files.
Rejected, with reasons:
The existing per-file and per-requester quotas stay. The per-session cap is an additional bound, not a replacement.
Requirements that follow
Still needed - the per-session byte value itself, which is the only unfilled number left on this issue.
putting an eng on this one
Investigated, not built. One finding changes the shape, so I stopped before writing the substrate four capabilities sit on. Angie (ENG,
claudeseat).The finding: session partitioning and the per-requester quota are currently incompatible
Your retention decision says plainly:
Both cannot hold under the obvious implementation, because the 4 MiB quota is measured over the partition directory (
scratch.go:401-411,partitionBytes()walkss.root), and the partition is the requester (scratch.go:65-72). Repartitioning by session does not add a bound. It moves the existing one:maxScratchPartitionBytessilently stops meaning per-requester and starts meaning per-session.That is worse than a naming problem. Today one requester occupies at most 4 MiB. Under session partitioning, a requester with five live threads occupies five partitions, up to 20 MiB. The volume is a 128Mi
emptyDirwith asizeLimit, and overflowing that evicts the pod rather than refusing a write.So repartitioning without solving this makes the disk-fill risk you flagged strictly worse, and the failure mode is the whole service dropping rather than one member being told they are out of room.
The deploy values already carry the arithmetic this breaks:
That sentence stops being true the moment partitions become sessions.
Proposed shape: nest, do not rename
Both quotas hold because there are two things to measure rather than one. A flat rename gives only one.
The unfilled number
You asked for it derived from the volume rather than guessed. 1 MiB per session, on this reasoning:
Marked as a proposal rather than recorded as decided, since you asked for the value to be written down here and this is my derivation rather than your call.
What is already built, so nobody rebuilds it
The read side and ingress are done and in
main:scratch_list,scratch_read,scratch_search,scratch_write, uploads landing in a reserveduploads/, content sniffing rather than trusting the declared type, a Discord-CDN host allowlist, fail-soft to the transcript marker, and the path notice telling the model where the file is and that it is untrusted.What does not exist at all is retention. I grepped the package for evict, expire, cleanup, sweep, idle - nothing. Today the only thing collecting files is the pod dying, which is what the
emptyDircomment describes. Both of your timers and the ceiling are unimplemented, which matches Delphi's warning that a cleanup that never runs is indistinguishable from no policy.Why I stopped here
Everything above is one coherent change: session identity, nested partitioning, two timers, a per-session cap, oldest-first eviction, and a sweeper that provably runs. It is the substrate for four approved capabilities, and building it on a flat rename would put a pod-evicting disk bug under all four.
The layout question is cheap to reverse in isolation - it is an
emptyDir, so a rollout resets it - but it is expensive to reverse once image reading, spooling, and the file tools are all addressing files through it. That is the one thing here worth confirming before code rather than after.Ready to build the whole slice on a yes to the nesting shape and the 1 MiB value. If you would rather I picked and shipped, say so and I will, but I would be choosing the storage layout for four features on my own reading of one sentence in your retention comment.
Already built and merged on your earlier yes. This issue is what I failed to update, and that is the defect worth naming. Angie (ENG,
claudeseat).You said "Do your proposal" in session, I built the whole slice, and #857 merged. Then I never came back here. So the last comment on this issue stayed "ready to build on a yes" and the issue read as blocked on a question that had already been answered and shipped.
That is precisely the shape this thread already complained about once:
Same failure, inverted: the work was done and the record was not. Recording it now rather than only fixing it.
What is on
main, verified rather than assertedChecked against a fresh clone at
6e3d031, not from the PR flag:internal/community/sessionworkspace.go- session identity, both timers, the collector.scratch.go:82-89-<session>/<requester>nesting.agent.go:393- the sweeper starts with the service.scratch.go:498- eviction on the session cap.Against your decisions
SIRENS_ECHO_THREAD_SESSION_RETENTIONSIRENS_ECHO_DIRECT_SESSION_RETENTIONThe nesting is the part that made "both quotas stay" achievable. A flat rename would have moved the 4 MiB bound instead of adding one, and put a pod-evicting disk bug under all four dependent capabilities.
Two things found while building
Session age reads files only. Removing a file moves its directory's timestamp, so counting directories would let eviction keep a dead session alive forever. An empty session falls back to its own directory time so it still expires.
A pre-existing bug surfaced. An unmounted scratchpad wrote into the working directory:
EvalSymlinks("")returns".", so confinement passed against an empty root. It was inert only because measuring that root errored first, and my accounting change stopped erroring there. Now refused explicitly and pinned by a test. The property was accidental, and nothing tested it.Closing this
Every decision on this issue is implemented and every number is filled. The four capabilities that share the substrate - image reading #168, MCP spooling #217, the file tools #155 - carry their own issues and are unaffected by this closing.
One thing that follows and is not mine: the deploy values still say "an emptyDir dies with the pod, so a rollout is the reset and there is nothing to restore or evict." That was true before retention existed. There is a collector now, so that comment is stale in
coilyco-bridge/deploy.