Skip to content

feat(prompts): resource attachments - SOPs carry their templates and reference files (#1013)#1034

Merged
cjimti merged 1 commit into
mainfrom
feat/1013-prompt-attachments
Jul 23, 2026
Merged

feat(prompts): resource attachments - SOPs carry their templates and reference files (#1013)#1034
cjimti merged 1 commit into
mainfrom
feat/1013-prompt-attachments

Conversation

@cjimti

@cjimti cjimti commented Jul 23, 2026

Copy link
Copy Markdown
Member

What this is

A prompt is a procedure, and a procedure usually depends on material: the report template it fills, the checklist it follows, the brand header it embeds, the sample payload it matches. That material had no channel. An author either pasted file contents into the prompt text, which breaks for binary and drifts from the uploaded original, or wrote "use the standard template" and hoped the agent found it.

A prompt now carries an ordered list of attached managed resources. The link stores a resource id rather than a copy, so editing the uploaded file updates every prompt that attaches it.

Serving

A resolved prompt delivers its material after the prompt text, in the forms MCP already has for it:

material delivered as
text at or below 64 KiB EmbeddedResource carrying the contents inline
binary, or larger than the threshold ResourceLink the client reads on demand

Both prompts/get and manage_prompt use serve them. use additionally lists them in an attachments array with each item's URI, media type, size, and availability, so the agent can state what materials it received and which it did not. The framing message tells the agent the material is authoritative: fill an attached template rather than inventing a format, follow an attached checklist, read a link before using it.

The Go SDK we ship (go-sdk v1.6.1) supports both content forms; this is the first use of them in the codebase.

The scope rule

An attachment must be at least as widely visible as the prompt that carries it:

resource scope may be attached to
global any prompt
persona P personal prompts, and persona prompts scoped to exactly P
user the author's own personal prompts only

Otherwise a shared SOP arrives without its template for most of its audience, and the failure surfaces to a reader who cannot fix it instead of to the one person who can.

The rule is applied at three points:

  1. At attach time, so the author learns immediately, with a message naming the resource.
  2. On every prompt write, so a scope change or a promotion request that would strand material is refused. This is a property of the shared prompt store rather than a call in each handler, for the same reason list_changed is: every write path crosses the store, so no writer can widen a prompt past its materials and none of them needs its own copy of the rule. It governs scope, not readership.
  3. At serve time, against the caller, where a reader who cannot read an attachment is told only that some materials were not delivered, never their contents and never their names.

Attaching accepts a resource the caller can read or administer. Resource visibility grants an admin no persona they do not belong to, and only admins may edit a shared prompt, so read-alone would make the persona row of that table unreachable. Serve time stays strictly read-based.

Reading a prompt's attachment list is gated on prompt visibility, not merely on the prompt existing: the list names the material and who attached it.

Sharing a prompt person-to-person writes to the share store, not the prompt, so it does not cross the write-time gate. A recipient of a prompt carrying the author's private template receives it with that material reported undelivered, which the serve-time check guarantees. Flagged in the guard's own comment rather than silently assumed.

Deleting an attached resource

The attachment row deliberately carries no foreign key to the resource table, so the link outlives its target. The prompt still serves with the material noted as missing, the portal flags the broken link for repair, and the prompt stays editable. A cascading delete would erase the evidence that the SOP is now incomplete.

A resource read that fails for any other reason does block a scope change, because an unknown scope is not a safe scope.

Portal

The prompt viewer gains an Attached materials panel: a searchable picker over the caller's visible resources with a category filter, ordering controls, and detach. The order is authored, not incidental, because it is the order the agent receives them in. Rows show each item's scope, so the rule that governs promotion is visible while authoring. The resource detail view lists the prompts that attach a resource, so the cost of deleting it is visible first.

Structure

REST lives in a new pkg/prompt/attachhttp serving both the portal and admin surfaces from one implementation, beside pkg/prompt rather than inside pkg/portal or pkg/admin, which sit at the package-size budget. Its caller identity is resource.Claims built by the same resolver the resources API uses, so the read rule applied to an attachment is byte-for-byte the one applied to the resource itself.

prompts/get now carries the resolved PlatformContext on the context it passes down. It is not a tools/call, so nothing downstream had an identity to read, and the attachment read check needs the caller's subject and persona memberships.

resource.IsNotFound is new and load-bearing: the Postgres resource store reports a missing row as a wrapped sql.ErrNoRows rather than as a nil result, so a caller that must tell "deleted" from "the database is down" cannot do it by nil-checking. Every attachment path that reads a resource uses it, which is what makes a deleted resource a flagged link rather than a failed request.

resource.BuildClaims and resource.PersonaAdminRoles move the roles-to-claims mapping into the package that owns Claims, so the resources middleware, the attachment REST handler, and prompt serving derive them identically, over the caller's full persona membership rather than a single resolved persona.

Verification

make verify green.

Integration tests through the real assembled prompt path per the CLAUDE.md standard: a live mcp.Server, the real prompt-visibility middleware (which is what puts the caller's identity on the context the resolver reads), the real resolver over real stores, and a client session making genuine prompts/get and tools/call requests. Wire-level assertions pin the emitted content discriminators to text / resource / resource_link.

Live-tested against the dev stack (PostgreSQL + SeaweedFS, real HTTP MCP transport, migration 000088 applied):

  • A markdown template and a PNG attached over REST; prompts/get returned four messages: the prompt text, the framing, the template embedded with its contents, and the PNG as a resource link with its size.
  • manage_prompt use returned the same two in protocol form plus an attachments provenance array marking one embedded and one linked.
  • Attaching an analyst-scoped resource to a global prompt: 409, message naming the resource.
  • Promoting a personal prompt carrying that resource to global: 409, same message.
  • Deleting an attached resource: the prompt kept serving with 1 attached material was not delivered (1 no longer exists), the attachment list returned 200 with the row flagged broken, and editing the prompt still returned 200.
  • An admin (not an analyst) serving a prompt with an analyst-only attachment: the material was withheld with no name and no contents.

Eight Playwright specs cover the portal states against MSW: listing with scope and origin, the broken-link flag, the restricted-material flag, attach, detach, reorder, picker filtering, and the resource dependency list.

Closes #1013

…reference files (#1013)

A prompt is a procedure, and a procedure depends on material: the report
template it fills, the checklist it follows, the brand header it embeds, the
sample payload it matches. That material had no channel. An author either
pasted file contents into the prompt text, which breaks for binary and drifts
from the uploaded original, or wrote "use the standard template" and hoped.

A prompt now carries an ordered list of attached managed resources. The link
stores a resource id rather than a copy, so editing the uploaded file updates
every prompt that attaches it.

A resolved prompt delivers the material after the prompt text, in the forms MCP
already has for it: text at or below 64 KiB inline as an EmbeddedResource,
anything binary or larger as a ResourceLink the client reads on demand. Both
prompts/get and manage_prompt use serve them; use additionally lists them in an
attachments array with each item's URI, media type, size, and availability, so
the agent can state what it received. The framing text tells the agent the
material is authoritative: fill an attached template rather than inventing a
format, follow an attached checklist.

An attachment must be at least as widely visible as the prompt that carries it.
Global material attaches anywhere, persona material to personal prompts and to
persona prompts scoped to exactly that persona, private material only to its
author's own personal prompt. Otherwise a shared SOP arrives without its
template for most of its audience, and the failure surfaces to a reader who
cannot fix it instead of to the one person who can.

The rule is applied at three points. At attach time, so the author learns
immediately. On every prompt write, so a scope change or promotion request that
would strand material is refused with a message naming the resource. And at
serve time against the caller, where a reader who cannot read an attachment is
told only that some materials were not delivered, never their contents and
never their names.

That second check is a property of the shared prompt store rather than a call in
each handler, for the same reason list_changed is: every write path crosses the
store, so no writer can widen a prompt past its materials and none of them needs
its own copy of the rule. It governs scope, not readership; sharing a prompt
person-to-person writes to the share store and relies on the serve-time check.

Deleting an attached resource does not break the prompt. The attachment row
deliberately has no foreign key to the resource table, so the link survives its
target: the prompt still serves with the material noted as missing, the portal
flags the broken link for repair, and the prompt remains editable. A resource
read that fails for any other reason does block a scope change, because an
unknown scope is not a safe scope.

Authors manage attachments from the prompt viewer; the resource detail view
lists the prompts that attach a resource, so the cost of deleting it is visible
first.

REST lives in pkg/prompt/attachhttp and serves both the portal and admin
surfaces from one implementation, beside pkg/prompt rather than inside
pkg/portal or pkg/admin, which sit at the package-size budget. Its caller
identity is resource.Claims built by the same resolver the resources API uses,
so the read rule applied to an attachment is the one applied to the resource
itself.

prompts/get now carries the resolved PlatformContext on the context it passes
down. It is not a tools/call, so nothing downstream had an identity to read, and
the attachment read check needs the caller's subject and persona memberships.

resource.IsNotFound is new, and load-bearing: the Postgres resource store
reports a missing row as a wrapped sql.ErrNoRows rather than as a nil result, so
a caller that must tell "deleted" from "the database is down" cannot do it by
nil-checking. Getting that distinction wrong is what separates a flagged broken
link from a prompt frozen against every edit.

resource.BuildClaims and resource.PersonaAdminRoles move the roles-to-claims
mapping into the package that owns Claims, so the resources middleware, the
attachment REST handler, and prompt serving derive them identically.

Closes #1013
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.02821% with 70 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.40%. Comparing base (fb5d153) to head (e265a50).

Files with missing lines Patch % Lines
internal/httpserver/promptversions.go 0.00% 21 Missing ⚠️
pkg/resource/permission.go 0.00% 16 Missing ⚠️
pkg/admin/prompt_handler.go 0.00% 8 Missing ⚠️
internal/platform/promptlayer/listchanged.go 50.00% 5 Missing and 2 partials ⚠️
internal/platform/promptlayer/attachments.go 73.91% 3 Missing and 3 partials ⚠️
pkg/prompt/attachserve/attachserve.go 97.22% 4 Missing and 1 partial ⚠️
pkg/prompt/attachhttp/handler.go 98.41% 2 Missing and 1 partial ⚠️
internal/platform/promptlayer/use.go 77.77% 1 Missing and 1 partial ⚠️
pkg/resource/store.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1034      +/-   ##
==========================================
+ Coverage   89.38%   89.40%   +0.02%     
==========================================
  Files         475      480       +5     
  Lines       52028    52634     +606     
==========================================
+ Hits        46505    47060     +555     
- Misses       3665     3704      +39     
- Partials     1858     1870      +12     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cjimti
cjimti merged commit 6c9185f into main Jul 23, 2026
10 checks passed
@cjimti
cjimti deleted the feat/1013-prompt-attachments branch July 23, 2026 07:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

resources: prompt attachments - SOPs carry their templates and reference files

1 participant