Skip to content

Commit 91dc52d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24318-change-slug-to-cloud
2 parents e2e7019 + 7c7741d commit 91dc52d

25 files changed

Lines changed: 2026 additions & 76 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec/ui): `navItemVisibleDataNeedsDataSource` is exported from `@objectstack/spec/ui`, so a mirror of `NavigationItemSchema` attaches the spec's own rule by identifier
6+
7+
Clause-②: yes (widening)
8+
9+
- **What is added.** One function export, `navItemVisibleDataNeedsDataSource(item, ctx)`: the refinement `NavigationItemSchema` already runs, which refuses a navigation entry with no `dataSource` whose `visible` reads `data`. It is exported the way `objectNavTargetExclusivity`, `checkPageRegionPlacement` and `checkPageMainRegionWidth` are, so a hand-written mirror of the navigation item can chain the same function in its own `superRefine` instead of restating the rule.
10+
- **What does not change.** No schema accepts or refuses anything new. The issue is the same: one `custom` issue at `visible`, with the same message. `NavigationItemSchema` still attaches the same function, once, by name.
11+
- **What the function reads.** Both spellings of `visible`, so it answers the same on an entry as authored and as parsed:
12+
- the authored bare string (`'data.total == 1'`), normalised by `EvaluatedExpressionInputSchema`, the slot's own schema, exactly as the spec's parse normalises it;
13+
- the parsed `{ dialect: 'cel', source }` envelope.
14+
15+
A boolean or absent `visible` judges nothing. So a mirror whose own `visible` stays the authored string chains the function as it is and refuses what the spec refuses.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
'@objectstack/plugin-audit': minor
4+
---
5+
6+
The `sys_attachment` parent gate and the `sys_comment` moderation gate give the answer a by-id update of the parent record gets
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is removed, renamed or re-shaped, and no stored row is read, rewritten, converted or dropped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime accept set: the attachment parent gate and the comment moderation gate admit only where a by-id update of the parent record would be admitted. The remedy for a refused caller is an access decision on the parent record, not a rewrite. The exported port types AttachmentSecurityLike and CommentSecurityLike gain one optional member, which no caller has to supply. The other categories are closed on facts: both bumped packages publish (not unpublished); no ADR-0087 id covers these paths and this diff adds none (not registered, not already-registered); the port types stay concretely typed (not type-surface-only). -->
11+
12+
- Every limb that asks whether the caller may edit the parent record (attaching a file, editing or removing another user's attachment or comment, moving an attachment to another record, and `assertMayAttach`) now admits only where a by-id update of that parent admits. After record sharing and the master-detail check, the gate asks the security service's judgement of that update: its record-grained `explain` verdict for `update` on the parent, `allowed` and `record.visible` both.
13+
- The step only adds refusals, with each limb's existing envelope (`ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`, or the not-visible `PERMISSION_DENIED` for a caller who cannot read the parent). A store fault keeps its own status, and an attach naming a parent object the registry does not hold answers what a by-id update of it answers (`404 OBJECT_NOT_FOUND`).
14+
- Posting a comment is unchanged: it stays a read rule on the parent.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(service-automation): a `runAs: 'user'` flow now carries its user's email, so a row policy on `current_user.email` admits the run on the rows it admits for the same user's own request
7+
8+
Clause-②: yes (widening)
9+
10+
A `runAs: 'user'` flow's data nodes ran without the acting user's email, which the request path resolves for every user. The row-level security compiler reads `current_user.email` from the execution context's `email` and nothing else, so every owner policy keyed on it (such as `assignee == current_user.email`) dropped out inside the run. A record-change flow triggered by a member whose only access to a row is such a policy was refused on the row the member had just written, and could read and update with their own request.
11+
12+
Run setup now seeds the email through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`, the user's stored `sys_user` email), and every data node passes it on:
13+
14+
- It is seeded for every trigger shape that names a user and carries no email: the record-change shape (`userId` and `tenantId` only) and the REST and action doors' shape (positions and permission sets already resolved). Only the missing facts are taken from the resolver; forwarded positions and permission sets stay exactly as they were.
15+
- A context that already carries an email keeps it verbatim, as a request's session email wins over the stored one. A `subflow` or `map` child and a suspended run keep the email their run was seeded with.
16+
- After an approval decision, the nodes that run as the decider carry the decider's own email, resolved for them. The submitter's email is removed with the rest of the submitter's grants, on both `runAs` postures.
17+
- A user with no stored email, or a resolver failure, leaves the run without one: policies that read it drop out, and the run sees less, never more. The `[runAs]` warning on a resolver failure now names the email beside the peer list.
18+
- A run suspended before this release resumes without the email until it ends.
19+
20+
A `runAs: 'system'` flow is unchanged. The flow CEL scope's `current_user` is unchanged too: it still binds no email.
21+
22+
Published types: `AutomationContext` (`@objectstack/spec/contracts`) gains an optional `email` member, and `RunIdentityContext` / `RunDataContext` and `FlowUserGrants` (`@objectstack/service-automation`) each gain an optional `email` member.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a `date` dimension buckets on its own calendar day under every timezone on the in-memory path, as the SQL pushdown path already did
6+
7+
Clause-②: no (no authorable key, export or signature moves; the in-memory answer for a `date` dimension under a non-UTC zone now equals the stored day, which is what the pushdown path answers)
8+
9+
- **A `Field.date` is a calendar day, not an instant (ADR-0053).** When `engine.aggregate` buckets in memory (any non-UTC `timezone` forces that path), a `groupBy` item with a `dateGranularity` on a field the object declares a `date` is now labelled by that field's own `YYYY-MM-DD`, with no zone. Before, the value was read as that day's UTC midnight and placed on the requested zone's calendar, so in every zone west of UTC each row landed one day early: `2026-10-01` bucketed as `2026-09-30`, and month, quarter and year totals moved across their edges (`POST /api/v1/analytics/query` with `timezone: "America/New_York"` answered `{2026-09: 1, 2026-10: 5, 2026-11: 4}` for six October and four November deliveries).
10+
- **A `datetime` keeps the zone.** An instant still lands on the requested zone's calendar day, unchanged. So does a field the declaration cannot type: `applyInMemoryAggregation` called without its `fields` argument, an undeclared field, or a `formula`.
11+
- **The value is read by the `date` storage rule first** (`temporalStorageForm(value, 'date')` in `@objectstack/core`): a bare `YYYY-MM-DD` is itself, and a `Date` handed back by a driver is its UTC calendar day.

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ separates *file storage* from *where a file is attached*:
1515

1616
The governing principle: **an attachment has no access model of its own — it
1717
inherits its parent record's.** A caller who can read a record can read its
18-
attachments; a caller who can edit a record can attach to and detach from it.
19-
Enforcement is layered, and every gate is fail-closed.
18+
attachments; a caller who may edit a record — a by-id update of it by that
19+
caller would be admitted — can attach to it, detach from it and re-point an
20+
attachment to it. Enforcement is layered, and every gate is fail-closed.
2021

2122
`Field.file` / `Field.image` are a **separate** path — those store an opaque
2223
`sys_file` id in the record's own column (ADR-0104 D3; the
@@ -32,24 +33,45 @@ Attachments panel renders only for such objects. Any other target is rejected:
3233

3334
| Code | Status | When |
3435
| --- | --- | --- |
35-
| `FILES_DISABLED` | 403 | Creating a `sys_attachment` whose `parent_object` does not declare `enable.files: true` |
36+
| `FILES_DISABLED` | 403 | Creating a `sys_attachment` whose `parent_object` is a registered object that does not declare `enable.files: true` |
3637

3738
This is enforced by a `beforeInsert` hook on `sys_attachment` and is
3839
independent of the record-level checks below.
3940

4041
## Create — parent-edit access & provenance
4142

42-
Creating a `sys_attachment` requires that the caller can **edit** the parent
43-
record — Salesforce edit-on-parent parity — verified with the sharing service's
44-
`canEdit`, so RLS / OWD / sharing of the parent object apply (public-model
45-
parents are editable by any member by design). When no sharing service is
46-
wired, the gate degrades to caller-scoped read visibility (a `findOne`).
47-
`uploaded_by` is **server-stamped** from the session; a client-supplied value
48-
is ignored.
43+
Creating a `sys_attachment` requires that the caller may **edit** the parent
44+
record — Salesforce edit-on-parent parity — and "may edit" has one meaning
45+
here: **a by-id update of that parent record, by the same caller, would be
46+
admitted.** The gate asks three questions in order and admits only when all
47+
three do:
48+
49+
1. record sharing (`ISharingService.checkEdit`);
50+
2. the master-detail check, for a `controlled_by_parent` parent
51+
(`ISecurityService.checkControlledByParentWrite`);
52+
3. the security service's judgement of that update — the record-grained verdict
53+
`security.explain` reports for `update` on the parent record (`allowed` and
54+
`record.visible` both).
55+
56+
The same rule governs every check that asks about the parent: attaching,
57+
detaching or editing another user's attachment, re-pointing an attachment to
58+
another record (judged on the new parent), and the file-attachments service's
59+
caller-scoped `assertMayAttach`. It never admits more than the parent's update
60+
does, and it can refuse more: record sharing is asked at the narrowest edit
61+
depth for these gates (the ruling recorded on `ISharingService`, "Write DEPTH
62+
is an input the CALLER supplies").
63+
64+
When no sharing service is wired, a caller-scoped read of the parent (a
65+
`findOne`) stands in for step 1. A kernel with no security service asks no
66+
update judgement, as the parent's own update meets none there. A store fault in
67+
step 2 or 3 keeps its own status (a `503` stays a `503`); it is never reported
68+
as a refusal. `uploaded_by` is **server-stamped** from the session; a
69+
client-supplied value is ignored.
4970

5071
| Code | Status | When |
5172
| --- | --- | --- |
52-
| `ATTACHMENT_PARENT_ACCESS` | 403 | The caller cannot access the parent record they are attaching to |
73+
| `ATTACHMENT_PARENT_ACCESS` | 403 | The parent record does not exist, or a by-id update of it by the caller would not be admitted (on a re-point, the new parent) |
74+
| `OBJECT_NOT_FOUND` | 404 | The `parent_object` names an object the registry does not hold — the answer a by-id update of it gets |
5375

5476
## Read / list — inherited parent visibility
5577

@@ -65,8 +87,9 @@ resolve the filter fails closed (deny-all).
6587
## Delete — uploader or parent editor
6688

6789
Deleting a `sys_attachment` is allowed when the caller is **the uploader** OR
68-
**can edit the parent record** (`sharing.canEdit` — public-model parents are
69-
editable by design). A multi-delete (`multi: true`) matches only attachments
90+
**may edit the parent record** — the parent-edit rule above: a by-id update of
91+
the parent by that caller would be admitted. The uploader keeps their own rows
92+
whatever the parent answers. A multi-delete (`multi: true`) matches only attachments
7093
the caller can read: an attachment whose parent is out of the caller's sight is
7194
not matched, not removed and not counted, so a predicate that reaches only such
7295
attachments answers exactly what a predicate that matches nothing answers. Every
@@ -81,7 +104,15 @@ matched row must then pass.
81104
An update of another user's attachment follows the same rule — the uploader or
82105
a parent editor — and a by-id update of an attachment the caller cannot read
83106
gets the same not-found answer. A multi-update matches only attachments the
84-
caller can read, as a multi-delete does.
107+
caller can read, as a multi-delete does. Re-pointing an attachment
108+
(`parent_object` / `parent_id`) to another record is an attach there: the new
109+
parent must pass the parent-edit rule too (`ATTACHMENT_PARENT_ACCESS`, 403).
110+
111+
The comment thread's moderation gate (`sys_comment`, `@objectstack/plugin-audit`)
112+
asks the same parent-edit rule: editing or removing another person's comment
113+
requires that a by-id update of the thread's parent record would be admitted,
114+
and the author keeps their own comments. Posting a comment stays a read rule on
115+
the parent.
85116

86117
The platform baseline also ships a parent-blind row-level delete floor
87118
(`owner_only_deletes`: you may delete only the rows you created, for members
@@ -181,7 +212,7 @@ can be shared across records). Reclamation is handled by the platform LifecycleS
181212
| Operation | Requirement | Deny code |
182213
| --- | --- | --- |
183214
| Attach (create) | parent object opts in (`enable.files`) | `FILES_DISABLED` (403) |
184-
| Attach (create) | can edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) |
215+
| Attach (create), re-point | a by-id update of the parent record by the caller would be admitted | `ATTACHMENT_PARENT_ACCESS` (403); `OBJECT_NOT_FOUND` (404) when the parent object is not registered |
185216
| List / read | inherits parent read visibility | *(filtered out)* |
186217
| Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` (403); `PERMISSION_DENIED` (403) when the parent is not readable or no delete grant is held |
187218
| Download | session + owner-or-parent-read (attachments scope); session only for a file with no parent; none for `acl: 'public_read'` | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |

‎packages/core/src/utils/datetime.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,16 @@ export function isBucketGranularity(value: unknown): value is BucketGranularity
290290
* built from those parts — the parts already carry the zone shift, so the week
291291
* boundary lands correctly without re-applying any offset.
292292
*
293+
* [#24618] `value` is an INSTANT, and the zone places it on a calendar. A
294+
* `Field.date` value is not one: it is a timezone-naive calendar day (ADR-0053,
295+
* "never converted to an instant and never timezone-shifted"), and handed in
296+
* with a zone it is read as that day's UTC midnight and lands on the PREVIOUS
297+
* day in every zone west of UTC. A caller bucketing a `date` therefore passes
298+
* no `timezone` — after putting the value into the `date` storage form
299+
* (`temporalStorageForm(value, 'date')`) — which is what
300+
* `@objectstack/objectql`'s rows path does for a field its object declares a
301+
* `date`. The field type is the caller's to read; this function never sees it.
302+
*
293303
* A finite NUMBER is read as epoch milliseconds — the form SQLite stores a
294304
* `Field.datetime` in, and what any driver that hands back raw storage values
295305
* yields. `new Date(String(1767225600000))` is an Invalid Date, so without this

‎packages/objectql/src/engine-aggregate-timezone.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,14 @@ async function makeEngine(rows: any[]) {
6161
const engine = new ObjectQL();
6262
engine.registerDriver(driver, true);
6363
await engine.init();
64+
// [#24618] `closed_at` holds INSTANTS, so it is declared a `datetime` — the
65+
// type whose buckets follow the reference zone. Declared a `date` (as it
66+
// was), the rows path buckets it by its calendar day under every zone
67+
// (ADR-0053), which is what in-memory-aggregation-date-calendar-day.test.ts
68+
// pins; the routing this file is about is the same for both types.
6469
engine.registry.registerObject({
6570
name: 'deal',
66-
fields: { closed_at: { type: 'date' }, amount: { type: 'number' } },
71+
fields: { closed_at: { type: 'datetime' }, amount: { type: 'number' } },
6772
} as any);
6873
return { engine, nativeCalls };
6974
}

0 commit comments

Comments
 (0)