Skip to content

Commit 99736a0

Browse files
os-zhuangclaude
andauthored
feat(storage): exclusive field-reference file ownership — ADR-0104 D3 wave 2 (PR-3) (#3527)
* feat(storage): exclusive field-reference file ownership — ADR-0104 D3 wave 2 (PR-3) A file/image/avatar/video/audio field holding a sys_file id now records its owner on the file itself: sys_file.ref_object / ref_id / ref_field name the single (object, record, field) slot that references it, maintained on the engine write path — claimed on insert, reconciled on update, released when the owning record is deleted. Field references are EXCLUSIVE, unlike attachments. The attachments surface deliberately shares one file across many sys_attachment join rows; a field reference is owned by at most one slot, and writing an already-owned id into a second slot copies the bytes into a fresh sys_file rather than sharing the row. Two properties follow, and both exist so a mistake cannot become unrecoverable: 1. A file's read authorisation derives from exactly one parent record instead of the union of every referrer's — so copying a private record's file id into a world-readable record, which generated code does without any visible error, cannot silently widen who can read the bytes. 2. Release is an OBSERVED transition ("my one owner let go"), never an inferred absence ("I counted and found nobody"). With no count, no miscount can authorise a delete — and a freshly-built ledger's ignorance of pre-existing references cannot either. Deletes nothing. This records and releases ownership; it never tombstones, and the scope==='attachments' guardrail that keeps field-referenced files out of the reap entirely is untouched, so shipping it cannot reclaim any bytes. Enabling collection must extend releaseOwnership AND the sys_file reap guard's sweep-time re-verify in the same change — half of it is worse than none, since tombstoning while the guard still re-verifies only sys_attachment (always empty for a field file) makes every release a guaranteed byte delete. Also lifts isFileIdToken into @objectstack/spec/data as the single arbiter of "is this stored string an opaque file id or a legacy/external URL?", replacing the private copy in the engine read resolver so the reader and the writer cannot drift into claiming files nobody expands, or vice versa. Dormant until a field actually holds an id token: objects without file-class fields, inline-blob values and URL-shaped values all exit before any I/O. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd * fix(storage): write \u0000-free same-slot check check:nul-bytes caught a raw NUL used as an impossible-record-id sentinel in the copy-on-claim same-slot comparison. A raw NUL makes ripgrep treat the whole file as binary, so it silently drops out of code search and every grep-based lint. Replaced with an explicit null check plus a named isSameSlot helper, which says what it means and is reused by claimFile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21ca1d5 commit 99736a0

12 files changed

Lines changed: 1301 additions & 15 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/service-storage": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
feat(storage): exclusive field-reference file ownership — ADR-0104 D3 wave 2 (PR-3)
7+
8+
A `file`/`image`/`avatar`/`video`/`audio` field that holds a `sys_file` id now
9+
records its owner on the file: `sys_file.ref_object` / `ref_id` / `ref_field`
10+
name the single `(object, record, field)` slot that references it, maintained on
11+
the engine write path — claimed on insert, reconciled on update, released when
12+
the owning record is deleted.
13+
14+
**Field references are exclusive, unlike attachments.** The attachments surface
15+
deliberately shares one file across many `sys_attachment` join rows; a field
16+
reference is owned by at most one slot, and writing an already-owned id into a
17+
second slot **copies the bytes into a fresh `sys_file`** rather than sharing the
18+
row. That keeps a file's read authorisation derived from exactly one parent
19+
record instead of the union of every referrer's — so copying a private record's
20+
file id into a world-readable one cannot silently widen access — and it removes
21+
reference counting from the lifecycle entirely: a file is released because its
22+
one owner let go, never because a count came back zero.
23+
24+
**Deletes nothing.** This records and releases ownership; it never tombstones,
25+
and the `scope === 'attachments'` guardrail that keeps field-referenced files
26+
out of the reap is untouched. Collection is a separate, gated change that must
27+
also extend the reap guard's sweep-time re-verify in the same commit.
28+
29+
Also exports `isFileIdToken` from `@objectstack/spec/data` as the single arbiter
30+
of "is this stored string an opaque file id, or a legacy/external URL?", now
31+
shared by the read resolver and the write claimer so the two cannot drift.
32+
33+
Dormant until a field actually holds an id token: objects without file-class
34+
fields, inline-blob values and URL-shaped values all exit before any I/O.

docs/adr/0104-field-runtime-value-shape-contract.md

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# ADR-0104: Field runtime value-shape as a first-class contract — spec-owned value schemas, typed action handlers, file-as-reference
22

33
- **Status**: Accepted (2026-07-22) — staged per D4. D1 landed (#3429), D2
4-
landed (#3432); D3 refined into two waves by the 2026-07-24 addendum below.
5-
Strict-default flip of D1/D2 tracked in #3438.
4+
landed (#3432); D3 refined into two waves by the 2026-07-24 addendum below,
5+
and wave 2's ownership model settled by the 2026-07-27 addendum.
6+
Strict-default flip of D1/D2 tracked in #3438; wave 2 sequence in #3459.
67
- **Date**: 2026-07-22
78
- **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param
89
silently stripped); relates #3407 (silently dropped writes), #1878 / #1891
@@ -481,12 +482,119 @@ rode v16/v17) to amortise the migration cost:
481482
under AI authoring this is *desirable*: it disambiguates "managed file" from
482483
"external link" so the AI cannot conflate them.
483484

485+
## Addendum (2026-07-27) — wave 2's ownership model: field references are exclusive
486+
487+
The 2026-07-24 addendum left one thing open, and it turned out to be the
488+
decision the rest of wave 2 hangs off: **when a field holds a `sys_file` id,
489+
what is the relationship between file and record?** Implementing the reference
490+
rows forced the answer.
491+
492+
### Two lineages, and why we take one of each
493+
494+
Enterprise platforms solve file lifecycle two ways:
495+
496+
- **Junction-for-everything** (Salesforce `ContentDocumentLink`, SAP GOS): every
497+
reference is a row in one polymorphic link table; a file is shared, and dies
498+
when the last link does. Sharing is the feature.
499+
- **Column-as-reference** (Dataverse File/Image columns): the column *is* the
500+
reference; lifecycle follows the row; no sharing.
501+
502+
ObjectStack already runs the first for its attachments surface (`sys_attachment`
503+
+ the ADR-0057 tombstone/reap), and that is right — attaching one document to
504+
several records is exactly what that surface is for, and the user performs the
505+
attach explicitly.
506+
507+
**Field references take the second model.** A `product.image` is a *property of
508+
that product*, and properties are copied, not shared. Concretely: at most one
509+
`(object, record, field)` slot owns a given `sys_file`; the owner is recorded on
510+
`sys_file.ref_object` / `ref_id` / `ref_field`; writing an already-owned id into
511+
a second slot **copies the bytes into a fresh `sys_file`** rather than sharing
512+
the row.
513+
514+
### Why exclusivity, decided on failure modes rather than elegance
515+
516+
Sharing is more storage-efficient and matches the better-known lineage. We take
517+
exclusivity anyway, because under AI-authored metadata the two models fail
518+
differently and the difference is not symmetric.
519+
520+
The naive generated clone — `insert({...src, id: undefined})` — spreads a file
521+
id into a second record. Every model must let that code *work*; rejecting it
522+
would force AI authors to learn a bespoke file API, which is precisely the kind
523+
of special case that produces mistakes. So the real question is what happens
524+
*after* it works:
525+
526+
| | worst case | recoverable? |
527+
|---|---|---|
528+
| Shared + reference-counted | file's readable-by set becomes the **union of every referrer's** — copying a private record's id into a world-readable record silently widens access; and a single miscount authorises an **irreversible byte delete** | no |
529+
| Exclusive + copy-on-claim | duplicated bytes | yes — it costs money, not data |
530+
531+
Read authorisation for attachment files already derives from the parent record.
532+
Under sharing, "the parent record" is ambiguous, and the safe reading of an
533+
ambiguous ACL is the union — which means generated code can leak data while
534+
containing no visible error. Exclusivity removes the ambiguity by construction:
535+
one file, one parent, one ACL.
536+
537+
The storage cost this concedes belongs at a different layer anyway: **dedup the
538+
bytes, not the rows.** Content-hash dedup behind the storage adapter (`etag` is
539+
already carried on `sys_file`) recovers the savings while leaving every row its
540+
own owner and its own ACL — the lifecycle never has to reason about sharing.
541+
A genuinely shared library asset should be modelled as its own object with a
542+
lookup, or attached through the attachments surface; steering AI authors there
543+
is the correct outcome, not a limitation.
544+
545+
### The safety principle this buys: observed transition, never inferred absence
546+
547+
> A file becomes collectable only because the platform **observed its one owner
548+
> let go** — never because a scan **inferred** that nobody references it.
549+
550+
The existing attachment path already obeys this (the tombstone is set by the
551+
hook that watched the join row die, not by a sweep that found none). Exclusive
552+
ownership extends the same discipline to fields, and eliminates counting
553+
entirely: with no count, no miscount can authorise a delete. It also defuses the
554+
cold-start hazard a new ledger would otherwise carry — a freshly-built reference
555+
table knows nothing of references that predate it, so trusting its zeroes would
556+
mass-delete history.
557+
558+
### Consequent resequencing of wave 2
559+
560+
The 2026-07-24 plan packaged the write cutover and the GC enable together, and
561+
placed the backfill after. That order is wrong: **a ledger may not authorise
562+
deletion until it has been backfilled and reconciled.** Wave 2 therefore
563+
sequences as:
564+
565+
1. **Ownership bookkeeping**`ref_*` columns, claim/release on the write path,
566+
copy-on-claim. Records ownership; deletes nothing. Dormant until a field
567+
actually holds an id, so it lands safely ahead of the cutover.
568+
2. **Governed download** — read authorisation derived from the one owning
569+
record; anonymous capability URL demoted to opt-in `acl: 'public_read'`.
570+
3. **Write cutover** (protocol major) — stored form narrows to an id;
571+
`accept` / `maxSize` enforced. Still deletes nothing.
572+
4. **Backfill**`os migrate` converts legacy inline blobs to `sys_file` rows
573+
and claims them; external URLs reported, not converted.
574+
5. **Reconciliation soak**`os storage verify-references` compares what
575+
records actually hold against recorded ownership, reporting *over-claim*
576+
(safe: file retained longer than needed), *under-claim* (**blocking**: a held
577+
file with no owner would be treated as free), and unclaimed orphans.
578+
6. **GC enable***gated, irreversible*. Relaxing the `scope === 'attachments'`
579+
tombstone guardrail and extending the `sys_file` reap guard's sweep-time
580+
re-verify to the ownership columns **must ship in the same change**. Half of
581+
it is worse than none: tombstoning released files while the guard still
582+
re-verifies only `sys_attachment` — always empty for a field file — turns
583+
every release into a guaranteed byte delete rather than a risky one.
584+
585+
R4's acceptance gate is now executable rather than aspirational: step 6 may not
586+
merge until step 5 reports **zero under-claims for ≥7 consecutive days** on real
587+
tenant data. R5 (public-posture inventory) and R6 (sub-key read scan) stand.
588+
484589
### Why this stays inside ADR-0104 rather than a new ADR
485590

486591
D3 is already this ADR's third phase; the two-wave split and the
487592
enforcement-point principle are a **refinement of the D4 rollout**, not a new
488593
decision, so they live here. Wave 2's migration mechanics (dual-read window,
489594
`os migrate` backfill, the R4/R5/R6 gates) are specified in §D3 above and need
490-
no separate record. Should wave 2's implementation surface a genuinely new
491-
decision (e.g. the reference-table shape, or a chunked-migration protocol), that
492-
specific choice — not file-as-reference as a whole — would earn its own ADR.
595+
no separate record. Wave 2's implementation did surface one genuinely new
596+
decision — the exclusive-ownership model — and it is recorded as the 2026-07-27
597+
addendum above rather than a separate ADR, because it settles *how* D3's
598+
already-accepted "field values point into `sys_file`" behaves rather than
599+
revisiting whether to do it. A future choice that stands on its own (say, a
600+
chunked-migration protocol, or byte-layer content dedup) would earn its own ADR.

packages/objectql/src/engine.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
type DroppedFieldsEvent
1313
} from '@objectstack/spec/data';
1414
import type { WriteObservabilityOptions } from '@objectstack/spec/contracts';
15-
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES } from '@objectstack/spec/data';
15+
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, isFileIdToken } from '@objectstack/spec/data';
1616
import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel';
1717
import { IDataDriver, IDataEngine, Logger, createLogger, withTransientRetry, type RetryOptions } from '@objectstack/core';
1818
import { SummaryRecomputeError, type SummaryRecomputeFailure } from './summary-errors.js';
@@ -2142,14 +2142,6 @@ export class ObjectQL implements IDataEngine {
21422142
return records;
21432143
}
21442144

2145-
/**
2146-
* Whether a value is an opaque `sys_file` id token — the minted uuid/nanoid
2147-
* form (letters, digits, `_`, `-`, bounded length), and crucially NOT a URL:
2148-
* a `https://…` / `/api/…` / `data:…` / `blob:…` file value carries `:`, `/`
2149-
* or `.` and is left untouched (it is a legacy/external URL, not a reference).
2150-
*/
2151-
private static readonly FILE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
2152-
21532145
/**
21542146
* Resolve file-field id references to their expanded `FileValueSchema` form
21552147
* (ADR-0104 D3 wave 2). A `file`/`image`/`avatar`/`video`/`audio` value
@@ -2190,7 +2182,7 @@ export class ObjectQL implements IDataEngine {
21902182
// what keeps a seeded `data:`/CDN image value from firing a bogus lookup.
21912183
const candidateIds: string[] = [];
21922184
const addCandidate = (v: unknown) => {
2193-
if (typeof v === 'string' && ObjectQL.FILE_ID_RE.test(v)) candidateIds.push(v);
2185+
if (isFileIdToken(v)) candidateIds.push(v);
21942186
};
21952187
for (const record of records) {
21962188
for (const fieldName of fileFields) {

0 commit comments

Comments
 (0)