Problem
ScopedStack.create() forces entityId to the requester but forwards every other option untouched:
return this.stack.create(typeId, content, { ...opts, entityId: requester });
A requester whose only authorization is a create grant on one type gets to set permissions, parentId, appId, and associations on records written into the owner's stack. One of these is a genuine privilege escalation; the rest are integrity/attribution problems.
The escalation: attachment associations grant file access
Attachment access is reference-implies-access: ScopedStack.getAttachment(fileId) succeeds if the requester can read any record carrying an attachment association for that fileId. But nothing gates the creation of such references on having access to the file being referenced.
Walkthrough, using the spec's own example default grant (['create', 'read-own'] on some type):
- Requester learns a private file's
fileId out-of-band (or from any leak — fileIds are SHA-256 content hashes, so they're also guessable for low-entropy content).
scopedStack.create(typeId, content, { associations: [{ kind: 'attachment', label: 'x', fileId }] }) — allowed, because only the create grant is checked.
- The requester can read their own record via
read-own → getAttachment(fileId) finds a readable referencing record → bytes disclosed.
The same hole exists post-create for anyone holding an update-own grant: associate() checks write access to the record, never access to the file being attached. The root cause is the asymmetry: access flows from references, but reference creation is ungated.
The content-addressing wrinkle makes this worse than it looks: because fileId is a hash of the bytes, step 3 doubles as a confirmation oracle — attach a guessed hash and try to read it, and success confirms the stack contains those exact bytes (e.g. a specific document).
Field-by-field
| Option |
Risk |
Proposed handling |
associations (attachment kind) |
Escalation — file access + content-confirmation oracle (above) |
Gate on file access (below) |
associations (relationship kind) |
Hygiene — pollutes relatedTo reverse-queries on records the requester can't read; spam/planted references surfacing in the owner's views |
Require read access to the referenced record |
associations (tag kind) |
None |
Allow |
parentId |
Integrity — inject records into arbitrary hierarchies; surfaces in the owner's parentId queries under trees the requester knows nothing about |
Require read access to the parent |
appId |
Attribution spoofing — claim to be any app |
Document as untrusted (see note) |
permissions |
Policy question, not strictly an escalation — ScopedStack.setPermissions() already lets the record creator set permissions post-create, so create-time is consistent with that. But it means a contributor can make content public, or grant an accomplice write, inside the owner's stack |
Keep consistent with setPermissions; surface the policy question |
Proposed changes
1. Gate attachment references on file access — at every reference-creation point
Define a single predicate, used by ScopedStack.create() (create-time associations) and ScopedStack.associate():
A requester may attach fileId to a record iff they are the owner, they uploaded the file (own an _attachment@1 record for it), or they can already read some record referencing it.
This is exactly the existing getAttachment() access rule — reference creation should require what reference possession grants. Failing the check throws StackPermissionError.
Anti-oracle detail: the error for "file doesn't exist" and "file exists but you can't reference it" must be indistinguishable, or the check itself becomes the confirmation oracle described above.
2. Gate parentId and relationship targets on read access
Same shape: creating a record under parentId, or a relationship association to recordId, requires the requester can read the referent. Same anti-oracle rule: missing and unreadable referents produce the same error. (Today Stack.create() doesn't validate parentId existence at all — a separate small gap this would subsume for the scoped path.)
3. appId: document as self-reported
App identity has no verification mechanism yet (the spec already notes _app records are only "a foundation for future enforcement in the API adapter"). Stripping it from scoped creates would break legitimate multi-app attribution, so: keep it, but the spec's Permissions section should state plainly that appId is untrusted metadata — never a permission input. Verified app identity is future work (and orthogonal to entity identity, #49).
4. permissions at create: keep, but state the policy
Aligning create-time with the existing setPermissions policy (creator may set permissions on their own record) is coherent — but the spec should say explicitly that this is deliberate: a contributor can widen access to records they author in your stack, up to and including public. If that's not wanted, the alternative is restricting scoped setPermissions/create-time permissions to targets the owner has whitelisted — noted as an open question rather than proposed, since it adds real machinery.
5. Tests
- Create-with-attachment-association by a grantee without file access →
StackPermissionError, and the error matches the nonexistent-file case byte-for-byte.
associate() attachment gating for update-own holders.
parentId / relationship-target read checks, including the missing-vs-forbidden indistinguishability.
- Regression: owner and uploader flows unaffected; tag associations unaffected.
Non-goals
Depends on the uploader-check pagination fix in #50 (the "uploaded the file themselves" predicate must scan all pages, or the gate itself false-denies). Refs #45 (trust boundary), #49 (identity — DIDs change who entityId names, not this logic).
Problem
ScopedStack.create()forcesentityIdto the requester but forwards every other option untouched:A requester whose only authorization is a
creategrant on one type gets to setpermissions,parentId,appId, andassociationson records written into the owner's stack. One of these is a genuine privilege escalation; the rest are integrity/attribution problems.The escalation: attachment associations grant file access
Attachment access is reference-implies-access:
ScopedStack.getAttachment(fileId)succeeds if the requester can read any record carrying anattachmentassociation for thatfileId. But nothing gates the creation of such references on having access to the file being referenced.Walkthrough, using the spec's own example default grant (
['create', 'read-own']on some type):fileIdout-of-band (or from any leak — fileIds are SHA-256 content hashes, so they're also guessable for low-entropy content).scopedStack.create(typeId, content, { associations: [{ kind: 'attachment', label: 'x', fileId }] })— allowed, because only thecreategrant is checked.read-own→getAttachment(fileId)finds a readable referencing record → bytes disclosed.The same hole exists post-create for anyone holding an
update-owngrant:associate()checks write access to the record, never access to the file being attached. The root cause is the asymmetry: access flows from references, but reference creation is ungated.The content-addressing wrinkle makes this worse than it looks: because
fileIdis a hash of the bytes, step 3 doubles as a confirmation oracle — attach a guessed hash and try to read it, and success confirms the stack contains those exact bytes (e.g. a specific document).Field-by-field
associations(attachment kind)associations(relationship kind)relatedToreverse-queries on records the requester can't read; spam/planted references surfacing in the owner's viewsassociations(tag kind)parentIdparentIdqueries under trees the requester knows nothing aboutappIdpermissionsScopedStack.setPermissions()already lets the record creator set permissions post-create, so create-time is consistent with that. But it means a contributor can make content public, or grant an accomplice write, inside the owner's stacksetPermissions; surface the policy questionProposed changes
1. Gate attachment references on file access — at every reference-creation point
Define a single predicate, used by
ScopedStack.create()(create-time associations) andScopedStack.associate():This is exactly the existing
getAttachment()access rule — reference creation should require what reference possession grants. Failing the check throwsStackPermissionError.Anti-oracle detail: the error for "file doesn't exist" and "file exists but you can't reference it" must be indistinguishable, or the check itself becomes the confirmation oracle described above.
2. Gate
parentIdand relationship targets on read accessSame shape: creating a record under
parentId, or arelationshipassociation torecordId, requires the requester can read the referent. Same anti-oracle rule: missing and unreadable referents produce the same error. (TodayStack.create()doesn't validateparentIdexistence at all — a separate small gap this would subsume for the scoped path.)3.
appId: document as self-reportedApp identity has no verification mechanism yet (the spec already notes
_apprecords are only "a foundation for future enforcement in the API adapter"). Stripping it from scoped creates would break legitimate multi-app attribution, so: keep it, but the spec's Permissions section should state plainly thatappIdis untrusted metadata — never a permission input. Verified app identity is future work (and orthogonal to entity identity, #49).4.
permissionsat create: keep, but state the policyAligning create-time with the existing
setPermissionspolicy (creator may set permissions on their own record) is coherent — but the spec should say explicitly that this is deliberate: a contributor can widen access to records they author in your stack, up to and includingpublic. If that's not wanted, the alternative is restricting scopedsetPermissions/create-time permissions to targets the owner has whitelisted — noted as an open question rather than proposed, since it adds real machinery.5. Tests
StackPermissionError, and the error matches the nonexistent-file case byte-for-byte.associate()attachment gating forupdate-ownholders.parentId/ relationship-target read checks, including the missing-vs-forbidden indistinguishability.Non-goals
Stack-level (unscoped) behavior — the plainStackAPI is trusted-by-definition per the topology decision in Single-writer storage model: durability fixes for adapter-local + spec/README guidance on multi-app topology #45; all changes here areScopedStack-only.Depends on the uploader-check pagination fix in #50 (the "uploaded the file themselves" predicate must scan all pages, or the gate itself false-denies). Refs #45 (trust boundary), #49 (identity — DIDs change who
entityIdnames, not this logic).