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
16 changes: 16 additions & 0 deletions .changeset/22698-file-delegate-metadata-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': patch
'@objectstack/platform-objects': patch
---

`fileAccessDelegate` and the refused file marker now describe what the delegate decides on a record read, beside the download

Clause-②: no

Wording only: no key, type, schema shape or runtime behaviour changes. A record read now follows the download door's field-owned verdict for a file the record owns, so the texts an author reads understated what a declared delegate decides.

- **`fileAccessDelegate`.** This covers the object schema's description and TSDoc, the object form's help text in `en`, `zh-CN`, `ja-JP` and `es-ES`, and the generated reference pages. The named service authorizes downloads of files owned by the object's media fields. It also decides whether a reader who may not read `sys_file` sees those files' name, size and type in a record read of the object. It is asked once per owning record on each such read, so an implementation runs on reads, not only on downloads. A reader who may read `sys_file` never reaches it on a record read. It fails closed on both paths.
- **`FileRefusedValueSchema` (`{ id, metadataRefused: true }`).** The TSDoc and the `metadataRefused` description now give two cases for a reader who may not read `sys_file`: the record being read does not own the file, or the download verdict on the owning record did not allow it. A file the record owns and the verdict allows is served as the full file object.
- **`IFileAccessDelegate`.** The interface's TSDoc and its `authorizeFileRead` doc now name both questions one verdict answers: the download, and whether a reader refused `sys_file` sees the file's name, size and type in a record read (asked once per owning record per such read). The warning now covers metadata too: a permissive implementation leaks the metadata as well as the bytes.

**For authors.** Nothing to change in metadata. An existing delegate now also decides a refused reader's file metadata on every read. It should keep applying the same rule its service uses to read the record.
2 changes: 1 addition & 1 deletion content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -955,7 +955,7 @@ Metadata query with filtering, sorting, and pagination
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
| **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. |
| **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. |
| **fileAccessDelegate** | `string` | optional | Kernel service that authorizes downloads of files owned by this object's media fields, instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service (e.g. sys_approval_action → approvals). Fails closed. |
| **fileAccessDelegate** | `string` | optional | Kernel service that authorizes downloads of files owned by this object's media fields, and decides whether a reader who may not read sys_file sees their name, size and type in a record read of this object (asked once per owning record on each such read), instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service (e.g. sys_approval_action → approvals). Fails closed. |
| **validations** | `any[]` | optional | Object-level validation rules |
| **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). |
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/field-value.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Type: `string`
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | The sys_file id the record holds |
| **metadataRefused** | `true` | ✅ | The reader was refused the file's metadata (no read on sys_file): name, size and type are withheld. Distinct from an empty field, which holds no file at all. |
| **metadataRefused** | `true` | ✅ | The reader was refused the file's metadata (no read on sys_file, and the record being read does not own the file or its download verdict did not allow it): name, size and type are withheld. Distinct from an empty field, which holds no file at all. |


---
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ const result = ApiMethod.parse(data);
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
| **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. |
| **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. |
| **fileAccessDelegate** | `string` | optional | Kernel service that authorizes downloads of files owned by this object's media fields, instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service (e.g. sys_approval_action → approvals). Fails closed. |
| **fileAccessDelegate** | `string` | optional | Kernel service that authorizes downloads of files owned by this object's media fields, and decides whether a reader who may not read sys_file sees their name, size and type in a record read of this object (asked once per owning record on each such read), instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service (e.g. sys_approval_action → approvals). Fails closed. |
| **validations** | `any[]` | optional | Object-level validation rules |
| **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). |
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/system/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ Create a new object
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
| **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. |
| **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. |
| **fileAccessDelegate** | `string` | optional | Kernel service that authorizes downloads of files owned by this object's media fields, instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service (e.g. sys_approval_action → approvals). Fails closed. |
| **fileAccessDelegate** | `string` | optional | Kernel service that authorizes downloads of files owned by this object's media fields, and decides whether a reader who may not read sys_file sees their name, size and type in a record read of this object (asked once per owning record on each such read), instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service (e.g. sys_approval_action → approvals). Fails closed. |
| **validations** | `any[]` | optional | Object-level validation rules |
| **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). |
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
Expand Down Expand Up @@ -630,7 +630,7 @@ Create a new object
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
| **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. |
| **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. |
| **fileAccessDelegate** | `string` | optional | Kernel service that authorizes downloads of files owned by this object's media fields, instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service (e.g. sys_approval_action → approvals). Fails closed. |
| **fileAccessDelegate** | `string` | optional | Kernel service that authorizes downloads of files owned by this object's media fields, and decides whether a reader who may not read sys_file sees their name, size and type in a record read of this object (asked once per owning record on each such read), instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service (e.g. sys_approval_action → approvals). Fails closed. |
| **validations** | `any[]` | optional | Object-level validation rules |
| **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). |
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
},
fileAccessDelegate: {
label: "File Access Delegate",
helpText: "Kernel service that authorizes downloads of files owned by this object's media fields, instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service. Fails closed."
helpText: "Kernel service that authorizes downloads of files owned by this object's media fields, and decides whether a reader who may not read sys_file sees their name, size and type in a record read of this object (asked once per owning record on each such read), instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service. Fails closed."
},
lifecycle: {
label: "Lifecycle",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
},
fileAccessDelegate: {
label: "Delegado de acceso a archivos",
helpText: "Servicio del kernel que autoriza la descarga de archivos pertenecientes a los campos multimedia de este objeto, en lugar de comprobar si quien llama puede leer la fila propietaria. Para objetos cuyo acceso media un servicio. Falla cerrado."
helpText: "Servicio del kernel que autoriza la descarga de archivos pertenecientes a los campos multimedia de este objeto y decide si un lector que no puede leer sys_file ve su nombre, tamaño y tipo al leer un registro de este objeto (se consulta una vez por registro propietario en cada lectura de ese tipo), en lugar de comprobar si quien llama puede leer la fila propietaria. Para objetos cuyo acceso media un servicio. Falla cerrado."
},
lifecycle: {
label: "Ciclo de vida de los datos",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
},
fileAccessDelegate: {
label: "ファイルアクセス委譲サービス",
helpText: "このオブジェクトのメディア項目が持つファイルのダウンロードを認可するカーネルサービス。指定すると「呼び出し元がそのレコードを読めるか」では判定しなくなります。アクセスがサービスによって仲介されるオブジェクト向けで、判定できない場合は拒否します。"
helpText: "このオブジェクトのメディア項目が持つファイルのダウンロードを認可し、sys_file を読めない読者がこのオブジェクトのレコードを読んだときにそのファイルの名前・サイズ・種類を見られるかも判定するカーネルサービス(そうした読み取りのたびに、ファイルを持つレコードごとに一度問い合わせます)。指定すると「呼び出し元がそのレコードを読めるか」では判定しなくなります。アクセスがサービスによって仲介されるオブジェクト向けで、判定できない場合は拒否します。"
},
lifecycle: {
label: "データライフサイクル",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
},
fileAccessDelegate: {
label: "文件访问委托服务",
helpText: "授权下载该对象媒体字段所属文件的内核服务;有了它就不再以“调用方能否读取宿主记录”来判断。适用于访问权由某个服务裁决的对象。失败即拒绝。"
helpText: "授权下载该对象媒体字段所属文件的内核服务,并决定无权读取 sys_file 的读者在读取该对象记录时能否看到这些文件的名称、大小和类型(每次此类读取,对每条所属记录询问一次);有了它就不再以“调用方能否读取宿主记录”来判断。适用于访问权由某个服务裁决的对象。失败即拒绝。"
},
lifecycle: {
label: "数据生命周期",
Expand Down
23 changes: 19 additions & 4 deletions packages/spec/src/contracts/storage-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,15 +407,30 @@ export interface IStorageService {
* The approvals service already knows who may see a request's history, so it
* answers instead.
*
* One verdict answers two questions about a file owned by a record of that
* object (the file's `ref_object` / `ref_id`):
*
* - **Download.** May this caller download the file? The download door asks
* for the owning record, unless the caller uploaded the file.
* - **Metadata in a record read.** When a reader's own `sys_file` read is
* refused, may they see the file's name, size and type where a record read
* of that object returns it? Asked once per owning record on every such
* read, so an implementation runs on reads, not only on downloads. A denial
* leaves the field as the refused marker `{ id, metadataRefused: true }`.
*
* Implementations should apply the SAME rule that governs reading the record
* through their own API — not a looser one. The delegate widens who can reach
* the bytes, so a permissive implementation is a data leak.
* the bytes and the file's metadata, so a permissive implementation is a data
* leak of both.
*/
export interface IFileAccessDelegate {
/**
* May this caller download a file owned by `recordId` on the delegating
* object? Return `false` (never throw) to deny; a throw is treated as a
* denial too, since authorization must fail closed.
* May this caller read the files owned by `recordId` on the delegating
* object — download them, and, when the caller's own `sys_file` read is
* refused, see their name, size and type in a record read? Called by the
* download door, and once per owning record on each such record read, with
* the caller's execution context. Return `false` (never throw) to deny; a
* throw is treated as a denial too, since authorization must fail closed.
*/
authorizeFileRead(recordId: string, context: unknown): Promise<boolean>;
}
23 changes: 17 additions & 6 deletions packages/spec/src/data/field-value.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,11 +557,21 @@ export type FileReferenceIdValue = z.input<typeof FileReferenceIdValueSchema>;
* the bare id back — the same value an id with no committed file row, or a
* storage outage, produces — so a refused file and an absent one were one
* answer on the wire. This shape is the refusal said in-band: the id the
* record holds, and `metadataRefused: true`. Nothing else is served, because
* nothing else was read: no `name`, `size` or `mimeType`, and no `url` — the
* download door judges its own access (by the record that owns the file, not
* by `sys_file` read), so a consumer derives the stable `/files/:fileId`
* endpoint from the id exactly as it does for a bare id.
* record holds, and `metadataRefused: true`.
*
* That refusal is not the last word on a file the record OWNS. When a file's
* `ref_object` / `ref_id` name the record being read, the engine reads its row
* under the system context and puts the record to the download door's
* field-owned verdict — the owner object's `fileAccessDelegate` where it
* declares one, otherwise the caller's read of that record. A file the verdict
* allows is served as a `sys_file` reader sees it ({@link FileValueSchema}).
* So, for a refused reader, this marker means the record does not own the file
* (an id copied in from another record, an attachment-only or unclaimed file,
* an id with no row), or the verdict refused the owning record, or it could not
* be asked. Nothing else is served: no `name`, `size` or `mimeType`, and no
* `url` — the download door judges its own access (by the record that owns the
* file, not by `sys_file` read), so a consumer derives the stable
* `/files/:fileId` endpoint from the id exactly as it does for a bare id.
*
* Read-only by construction: the STORED form is
* {@link FileReferenceIdValueSchema} alone, so this object is never a value a
Expand All @@ -579,7 +589,8 @@ export const FileRefusedValueSchema = lazySchema(() => strictObject(
{
id: FileReferenceIdValueSchema.describe('The sys_file id the record holds'),
metadataRefused: z.literal(true).describe(
'The reader was refused the file\'s metadata (no read on sys_file): name, size and type are '
'The reader was refused the file\'s metadata (no read on sys_file, and the record being read '
+ 'does not own the file or its download verdict did not allow it): name, size and type are '
+ 'withheld. Distinct from an empty field, which holds no file at all.',
),
},
Expand Down
Loading
Loading