feat(prompts): resource attachments - SOPs carry their templates and reference files (#1013)#1034
Merged
Merged
Conversation
…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 Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
EmbeddedResourcecarrying the contents inlineResourceLinkthe client reads on demandBoth
prompts/getandmanage_prompt useserve them.useadditionally lists them in anattachmentsarray 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:
PPOtherwise 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:
list_changedis: 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.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/attachhttpserving both the portal and admin surfaces from one implementation, besidepkg/promptrather than insidepkg/portalorpkg/admin, which sit at the package-size budget. Its caller identity isresource.Claimsbuilt 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/getnow carries the resolvedPlatformContexton the context it passes down. It is not atools/call, so nothing downstream had an identity to read, and the attachment read check needs the caller's subject and persona memberships.resource.IsNotFoundis new and load-bearing: the Postgres resource store reports a missing row as a wrappedsql.ErrNoRowsrather 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.BuildClaimsandresource.PersonaAdminRolesmove the roles-to-claims mapping into the package that ownsClaims, 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 verifygreen.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 genuineprompts/getandtools/callrequests. Wire-level assertions pin the emitted content discriminators totext/resource/resource_link.Live-tested against the dev stack (PostgreSQL + SeaweedFS, real HTTP MCP transport, migration
000088applied):prompts/getreturned 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 usereturned the same two in protocol form plus anattachmentsprovenance array marking oneembeddedand onelinked.409, message naming the resource.409, same message.1 attached material was not delivered (1 no longer exists), the attachment list returned200with the row flaggedbroken, and editing the prompt still returned200.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