Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/26046-released-file-keeps-record-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/service-storage': patch
---

fix(storage)!: a file its record lets go keeps that record's read rule

ADR-0104 D3 promises one file, one owning record and one access rule. That rule now holds after the owning record lets the file go: until a writer who may read that record claims the file again, or the platform collects it, the file is read by that record's rule. The download door and the record-write claim path ask the same verdict, so both follow it. A file no record has owned is read as before, within its organization. The uploader still reads their own file.

`sys_file` gains two nullable fields, `released_ref_object` and `released_ref_id`, naming the record that last owned the file. The platform writes them when a record lets a file go and clears them when a record claims it. Existing rows hold NULL and are read as before.

Clause-②: no (narrowing) — a file read that a record's rule refuses is now refused after the record lets the file go. Nothing that was refused becomes accepted.

Metadata-protocol: none (a file read rule and two platform-managed bookkeeping fields; nothing a metadata author writes changes)

<!-- adr-0087: not-required (no-migration-prescription) No metadata author's surface moves: no spec key or authorable spelling is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. The platform object `sys_file` gains two nullable bookkeeping fields that only the platform writes; schema sync adds the columns and existing rows read NULL, which is the shape every row had before. What changes is which callers a file read admits after the file's owning record lets it go. The remedy is reading the file as a reader of that record, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**BREAKING** for a reader the owning record's rule refuses, shipped as `patch` because the metadata protocol does not move.

- **Before.** The owning record's rule did not hold for the file once the record let it go.
- **Now.** It is read by the rule of the record that let it go, until it is claimed again or collected.
- **Fix.** Read or claim such a file as a reader of that record.
8 changes: 5 additions & 3 deletions docs/adr/0104-field-runtime-value-shape-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -1459,9 +1459,11 @@ exists in the acting organization. One file had two access rules.
- **One judgement, two callers.** The door's verdict is one function, and the
claim path calls it under the write's caller context; neither restates it.
It admits the uploader; a reader of the record that owns the file, through
its object's `fileAccessDelegate` where one is declared; a member of the
organization for a file no record owns; a reader of any parent for an
attachments-scope file; anyone for a `public_read` file.
its object's `fileAccessDelegate` where one is declared; for a file a record
let go, a reader of that record, until the file is claimed again or
collected (#26046); a member of the organization for a file no record has
owned; a reader of any parent for an attachments-scope file; anyone for a
`public_read` file.
- **The quiet answer.** At the write door, a file the writer may not read is
answered as one the organization-scoped lookup cannot see: the id is kept
as written, nothing is claimed or copied, and constraint verdicts do not run
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ import { callerContext } from './attachment-access-hooks.js';
* and keep any other id as written; neither is a file the anonymous caller
* may provide. An empty or absent file field is written as before.
*
* ### A released file keeps its record's rule
*
* [#26046] Releasing a file clears its ownership columns, and the download
* door reads a file no record owns by the organization's rule. That rule is
* for a file no record ever held; a file a record let go is still the one that
* record held. So release keeps the record ({@link releaseOwnership}), the
* door's verdict judges the file by that record's read access until it is
* claimed again or collected, and a claim, which asks the same verdict, is
* open only to a writer who may read that record. A deleted record is read by
* nobody, so its released files are left to their uploader.
*
* ### The one write that cannot honour it, and is refused
*
* [#7102] A predicate (`multi: true`) update has ONE payload for N matched
Expand Down Expand Up @@ -803,6 +814,12 @@ async function claimFile(
patch.status = 'committed';
patch.deleted_at = null;
}
// [#26046] The claiming record's rule replaces the one the file kept from
// the record that let it go.
if (row.released_ref_object != null || row.released_ref_id != null) {
patch.released_ref_object = null;
patch.released_ref_id = null;
}
await engine.update('sys_file', patch, { context: { ...SYSTEM_CTX } });
}

Expand Down Expand Up @@ -848,6 +865,13 @@ async function collectionOpen(engine: FileReferenceEngine): Promise<boolean> {
* Only a `committed` file is tombstoned: `pending` rows already carry their
* own never-completed reap policy, and re-tombstoning a `deleted` row would
* reset its grace clock.
*
* [#26046] Every release keeps the record that let the file go
* (`released_ref_object` / `released_ref_id`), whether or not it tombstones:
* the file is read by that record's rule until a writer who may read it
* claims it again ({@link claimFile} clears them), or it is collected. Without
* them a released file would read as one no record ever held, whose rule is
* the organization's, which is weaker than the record's was.
*/
async function releaseOwnership(
engine: FileReferenceEngine,
Expand All @@ -861,7 +885,14 @@ async function releaseOwnership(
if (id == null) continue;
const tombstone = collect && row.status === 'committed';
try {
const patch: Record<string, unknown> = { id, ref_object: null, ref_id: null, ref_field: null };
const patch: Record<string, unknown> = {
id,
ref_object: null,
ref_id: null,
ref_field: null,
released_ref_object: row.ref_object ?? null,
released_ref_id: row.ref_id ?? null,
};
if (tombstone) {
patch.status = 'deleted';
patch.deleted_at = new Date().toISOString();
Expand Down
Loading
Loading