Skip to content

Commit b49aba4

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21437-empty-prefix-measure
2 parents 24924ee + 49524f6 commit b49aba4

39 files changed

Lines changed: 2054 additions & 207 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
Flow, hook, action, approval and expression rule findings no longer cite tracker numbers; each one states the decision behind it in words
6+
7+
Clause-②: no
8+
9+
Some findings these rules show to authors through `os validate`, `os lint` and `os build`, and the startup-registry findings a plugin author reads, pointed at an issue-tracker number for the reason behind them. The number goes; where the sentence did not already say what was decided, it now does.
10+
11+
- Flow patterns: the record-change date-equality hint and the date-equality filter hint name the declarative alternative, a `schedule` flow whose start node carries a `config.timeRelative` descriptor; the unscoped `runAs` hint says `runAs` is enforced, so a run with no trigger user has its data operations refused rather than run unscoped; the unbounded bulk-write hint says `multi: true` is how a flow declares bulk intent and that the engine admits a whole-object write declared that way; the revise-target hint says the run-resume route continues a pause on a service-owned node type only through the service that owns it; the two interpolation hints say a flow node value is a string template in which only single-brace tokens resolve.
12+
- Startup-registry findings: the open-vocabulary notes say the engine judges node types only once the vocabulary is sealed at `kernel:bootstrapped`; the prescription describes the lazy cache resolution and the ADR-0104 attestation by what each does; the assertive-wording finding describes its two incidents, and how each was fixed, in words.
13+
- Expression findings: the field-level `visibleWhen` consequence names the `current_user` binding ADR-0089 D1 gives every runtime record surface; the retired `script` keys finding says spec 17 made `script` a call to a registered function and nothing else.
14+
- Trigger readiness: the array `triggerType` hint says multi-event arrays are deferred until two independent projects need a combination other than created-or-updated.
15+
- Body writes, readonly writes and approvals: the discarded `ctx.record` write says the snapshot stays read-only by design and an action writes through `ctx.api`; the `readonlyWhen` write finding says a bulk update strips the field from every matched row once any one of them is locked; the `queue` approver finding says the type was deprecated rather than built; the empty-slate hint says the admin override may act on any pending request, so that one nobody in its slate can decide never stays stuck.
16+
- The other findings drop a citation the sentence already explained.
17+
18+
Text only: no rule id, severity, condition or finding moves. A tool or test that matches the old text (for example a tracker-number suffix) needs the new spelling.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-sharing': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(plugin-sharing): the record owner and an explicit Modify-All holder may mint a share link on a record the data door refuses them (ADR-0111 D8 rule 1, ruling A′) (#21329)
7+
8+
Clause-②: yes (widening)
9+
10+
- **Who may mint.** `ShareLinkService.createLink` admits the caller when they can see the record, **or** own it, **or** hold `modifyAllRecords` on the object. The object's `publicSharing` opt-in is still checked first, and `publicSharing.eligibility` still last. On an object declared `access: { default: 'private' }` no wildcard grant covers the record, so its owner's own read is refused; the owner can now share it anyway. A member who neither sees nor owns the record is refused exactly as before, with the same envelope.
11+
- **Who still needs visibility.** A hierarchy manager whose write depth covers the record's owner manages the record's shares (revoke, grant, list), but is not admitted to mint without seeing the record: a link creates access.
12+
- **The organization wall.** Under the `group` and `isolated` tenancy postures the owner and Modify-All alternatives are withheld and visibility alone admits, as before this release. A member who left an organization still owns the records they created there, and must not be able to publish them by link.
13+
- **A required capability.** Neither alternative applies past a capability the object requires (`requiredPermissions`). An owner or Modify-All holder who lacks it is refused with the capability gate's own refusal, as before this release; an owner who holds it, refused only because no permission set grants the object, mints. The verdict is read from the `required_permissions` layer of `ISecurityService.explain`, so a security service the sharing service reaches must implement `explain`. If it does not, the two alternatives are withheld.
14+
- **API.** `SharingService.canMintWithoutVisibility(object, recordId, context)` answers the two alternatives with the owner and Modify-All branches `canManageShares` reads. `ShareLinkServiceOptions.canMintWithoutVisibility` is the late-bound probe `createLink` asks once the visibility read refuses, and `SharingServicePlugin` wires it. A host that constructs `ShareLinkService` itself without it keeps the visibility rule alone. The probe slice `SharingServiceOptions.securityService` returns gains an optional `explain`, the part of `ISecurityService.explain` the capability verdict reads.
15+
- **`@objectstack/spec` (documentation only).** The `IShareLinkService.createLink` TSDoc states who may mint, replacing "you may only link-share a record you can yourself see". The `ISharingService.canManageShares` TSDoc describes the hierarchy-manager branch, which is implemented, and says it is not mint authority. No schema, key, type or export changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
Withdrawing a public form from anonymous intake now takes effect on every intake door
6+
7+
Clause-②: no
8+
9+
When an administrator withdraws a public form, both anonymous form routes (`GET /forms/:slug` and `POST /forms/:slug/submit`) now answer `404 FORM_NOT_FOUND` and no record is created. Republishing the form restores both routes. If a service the routes need to resolve the form is registered but cannot be reached, both routes refuse the request instead of serving the form.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
`field-no-consumers` no longer calls a field "inert" when a dataset or cube member reads it through a relationship path (#21439).
6+
7+
Clause-②: no
8+
9+
`os validate`, `os build` and `os lint` warned "Verdict: inert — no site of any kind names it" for every field an analytics member reached through a path such as `account.revenue`, so an author following the warning would delete a column a measure reads. The four slots that name a column are a dataset dimension's and measure's `field` and a cube dimension's and measure's `sql`. Each one now credits every field its path reads: the lookup on the base object, each intermediate lookup, and the column on the object the last hop reaches.
10+
11+
- **Hops resolve the way the analytics door resolves them.** A cube hop goes through the join the cube declares for it, else the lookup's `reference`. A dataset hop goes through the `reference` its compiler joins through, and only where the dataset's `include` declares the join.
12+
- **A bare cube column is credited too.** Before, a cube member's `sql: 'amount'` credited nothing, because a cube names its object in its own `sql`.
13+
- **A path the door refuses reads nothing.** Examples: a join the dataset's `include` does not declare, a hop that names no relationship, a column the last object does not have. Each field such a path names is now listed as a carrier site that a removal must clean (`carrier-only`), not as a reader.
14+
- **A path the object graph cannot judge** credits the fields it does resolve. An example is a lookup to an object this stack does not define.
15+
16+
Nothing new is refused, and the rule stays a warning. One warning can appear where there was none: a path the door refuses through a lookup named after its target object (`account.revenue`, with `account` a lookup to the object `account`). The old text scan credited its column as read. It is now reported `carrier-only`, beside the error the refused path already carries.

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -477,12 +477,24 @@ publicSharing:
477477
```
478478

479479
**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
480-
`publicSharing` opt-in **and** that the caller can see the record — an opted-in
481-
object deliberately delegates re-share power to anyone who can view the row.
480+
`publicSharing` opt-in **and** authority over the record: the caller can see it,
481+
**or** owns it, **or** holds `modifyAllRecords` on the object. An opted-in object
482+
deliberately delegates re-share power to anyone who can view the row; the owner
483+
and the Modify-All holder may mint even where the data door refuses them, as on
484+
an object declared `access: { default: 'private' }`, which no wildcard grant
485+
covers — so a member can share a record of their own there. A hierarchy manager
486+
whose write depth covers the owner is **not** admitted without visibility: a
487+
link creates access, so they need to see the record to mint one. Under the
488+
`group` and `isolated` tenancy postures the owner and Modify-All alternatives
489+
are withheld and visibility alone admits, so no mint crosses the organization
490+
wall. Nor do they apply past a capability the object requires
491+
(`requiredPermissions`): a caller who lacks it is refused even on a record they
492+
own. The opt-in is checked first and `eligibility` (below) last.
482493
Revoking a link is allowed for the link's **creator**, a **record share-manager**
483-
(the record's owner or a `modifyAllRecords` admin — the same authority as
484-
`canManageShares`), or system context: a link someone else minted on your record
485-
is your record's exposure to kill, not only its creator's.
494+
(the record's owner, a `modifyAllRecords` admin, or a hierarchy manager whose
495+
write depth covers the owner — the same authority as `canManageShares`), or
496+
system context: a link someone else minted on your record is your record's
497+
exposure to kill, not only its creator's.
486498

487499
**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
488500
predicate is a **standing policy about which records may be reached

‎docs/adr/0111-record-share-management-authority-and-verb-boundary.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0111: Record-share management authority and the verb boundary — sharing needs "who may manage a share" and "which verbs a level grants"
22

3-
**Status**: Accepted (2026-07-30) — **P0 + P1 + D8 (framework) implemented**. P0 (D1/D2/D4/D5/D6/D7/D9): `canManageShares` + `hasWriteBypass`, verified by the #3902 Mallory reproduction. P1 (D3, the verb boundary): `canDelete` + verb-split `buildWriteFilter` in `plugin-sharing/src/sharing-service.ts`, routed by the middleware and `/security/explain`, verified by the "edit share cannot delete" suite. D8 (share-link re-share): `ShareLinkService.revokeLink` now admits a record share-manager via the late-bound `canManageShares` probe, and the mint-authority ruling (publicSharing opt-in + visibility) is enforced as before — verified in `share-link-service.test.ts`. D1 DEPTH extension: `canManageShares` now admits a hierarchy manager whose effective write scope covers the record's owner, via the new `ISecurityService.resolveWriteScope` probe + the enterprise `hierarchy-scope-resolver` (framework side, fails closed to owner+Modify-All without the resolver). **Still open**: the cloud-side wiring for both D8 and the DEPTH extension (`HttpDispatcher.handleShareLinks` + a `.objectstack-sha` bump + `security-enterprise` integration tests, in `objectstack-ai/cloud`) — batched into one framework-SHA bump per this ADR's rollout.
3+
**Status**: Accepted (2026-07-30) — **P0 + P1 + D8 (framework) implemented**. P0 (D1/D2/D4/D5/D6/D7/D9): `canManageShares` + `hasWriteBypass`, verified by the #3902 Mallory reproduction. P1 (D3, the verb boundary): `canDelete` + verb-split `buildWriteFilter` in `plugin-sharing/src/sharing-service.ts`, routed by the middleware and `/security/explain`, verified by the "edit share cannot delete" suite. D8 (share-link re-share): `ShareLinkService.revokeLink` now admits a record share-manager via the late-bound `canManageShares` probe — verified in `share-link-service.test.ts`. **D8 rule 1 amended 2026-10-02** by maintainer ruling `5950188467` on objectstack-ai/objectstack#21329 (option A′): mint authority is the `publicSharing` opt-in AND (visibility, or the record owner, or an explicit Modify-All bypass), implemented as `ShareLinkService.createLink` asking `SharingService.canMintWithoutVisibility` (`canManageShares`' owner and bypass branches, without its DEPTH branch) once the visibility read refuses, with the two alternatives withheld under an organization wall and past a missing required capability — verified in `share-link-service.test.ts`, at both share-link doors, and at the organization wall. D1 DEPTH extension: `canManageShares` now admits a hierarchy manager whose effective write scope covers the record's owner, via the new `ISecurityService.resolveWriteScope` probe + the enterprise `hierarchy-scope-resolver` (framework side, fails closed to owner+Modify-All without the resolver). **Still open**: the cloud-side wiring for both D8 and the DEPTH extension (`HttpDispatcher.handleShareLinks` + a `.objectstack-sha` bump + `security-enterprise` integration tests, in `objectstack-ai/cloud`) — batched into one framework-SHA bump per this ADR's rollout.
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a security property that parses but enforces nothing is worse than absent), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (DEPTH scopes + the `sys_record_share` / `sys_sharing_rule` split), [ADR-0066](./0066-unified-authorization-model.md) (unified capability model; `modifyAllRecords` super-user bit), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert metadata — a persisted share level or recipient type that no gate consults is exactly this), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (D1 secure-default OWD, D4 retired aliases, D10 delegated identity intersection), [ADR-0091](./0091-grant-lifecycle-and-recertification.md) (time-boxed grants — the lifecycle axis this ADR deliberately does not re-open)
66
**Consumers**: `@objectstack/plugin-sharing` (`sharing-service.ts`, `sharing-rule-service.ts`, `share-link-service.ts`, `sharing-plugin.ts`), `@objectstack/plugin-security` (`ISecurityService` — a write-bypass probe), `@objectstack/rest` (`rest-server.ts` sharing / sharing-rule / share-link routes), `@objectstack/spec` (`contracts/sharing-service.ts`, `security/capabilities.ts`)
@@ -143,7 +143,12 @@ Three write-time refusals so a persisted share always means something:
143143

144144
Two policy rulings on the already-hardened link surface:
145145

146-
1. **Mint authority = record visibility AND the object's `publicSharing` opt-in.** A `publicSharing`-enabled object deliberately delegates re-share power to anyone who can see the record; this is now a **stated** decision rather than an emergent one. Objects that do not opt in cannot be link-shared at all. (A future tightening to require share-management authority for minting is recorded as D-future, not taken now — it would break existing `publicSharing` flows.)
146+
1. **Mint authority = the object's `publicSharing` opt-in AND (record visibility, or the record owner, or an explicit Modify-All bypass).** A `publicSharing`-enabled object deliberately delegates re-share power to anyone who can see the record; this is now a **stated** decision rather than an emergent one. Objects that do not opt in cannot be link-shared at all, by their owner included. (A tightening to require share-management authority for every mint stays recorded as D-future and untaken — it would break existing `publicSharing` flows. The amendment below moves the other way, and for two principals only: it admits authority *beside* visibility, never in place of it.)
147+
148+
*Amended by maintainer ruling `5950188467` (objectstack-ai/objectstack#21329, option A′).* On an owner-private object (`access.default: 'private'`) the data door refuses the record's owner too, so on visibility alone an owner could never share their own record. Two principals may therefore mint where the visibility read refuses them: the **record owner**, the one principal whose own record is the thing shared, and an **explicit `modifyAllRecords` holder**, who already reads everything. These are `canManageShares`' own owner and bypass branches (D1), read once — no second notion of ownership. A **hierarchy manager still needs visibility** to mint: their D1 DEPTH authority covers revoke, grant and list (rule 2, D4, D5), but a link *creates* access, and write depth can cover a record the data door will not show them. The opt-in is checked first and `publicSharing.eligibility` last, against the row the anonymous holder would be served. Two withholdings narrow the ruled letter. Each is a named part of this amendment's approval, and where one applies visibility alone admits, as before the amendment:
149+
- **The organization wall.** Where a wall is in force (`group` / `isolated`, ADR-0105 D1) both alternatives are withheld and visibility alone admits: the visibility read is what applies Layer 0, the owner column outlives a membership, and the Modify-All probe is object-wide, so neither alternative may carry a mint across the wall.
150+
- **A required capability.** When the visibility read was refused because the caller lacks a capability the object requires (`requiredPermissions`, ADR-0066 D3), that refusal is a hard stop and neither alternative applies past it. The owner exception is about the record row — the owner's own record is the thing shared — not about a capability an administrator withheld from the caller for the whole object, and a Modify-All holder without it does not "already read everything". The gate's verdict is the declared `required_permissions` layer of `ISecurityService.explain`, which the explain engine computes with the read gate's own capability fold, so it tells this refusal apart from the owner-private CRUD refusal without a new contract method.
151+
147152
2. **A record's share-manager may revoke any link on that record.** Today `revokeLink` is creator-or-system only, so a record owner / `modifyAllRecords` admin cannot kill a link someone else minted on their record. Revoke authority becomes: creator **or** `canManageShares(link.object, link.recordId, context)` **or** system.
148153

149154
### D9 — A dedicated `manage_sharing` capability
@@ -174,7 +179,7 @@ Buffers instead of a valve: (1) every fail-closed denial logs a specific reason
174179
**Negative / costs**
175180
- Two breaking changes (D3, D7). Mitigated by narrow blast radius, deny-logging, `explain`, and changelog per D10.
176181
- A new cross-plugin contract method (`ISecurityService.hasWriteBypass`) — small, fails closed, mirrors the existing `getReadFilter` posture.
177-
- The MVP does not give hierarchy managers share-management authority (D1 DEPTH is deferred); until the extension lands, a manager sharing a subordinate's record is done by an admin or the owner.
182+
- Hierarchy managers' share-management authority (D1 DEPTH) landed after the MVP and depends on the enterprise hierarchy resolver: without it the gate fails closed to owner + Modify-All. Even with it, that authority covers grant, revoke and list, never minting a share link on a record the manager cannot see (D8 rule 1).
178183

179184
**Neutral / explicitly out of scope**
180185
- **Time-boxed / recertifiable shares** — owned by ADR-0091; this ADR does not touch the lifecycle axis.

‎packages/lint/src/lint-flow-patterns.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1751,7 +1751,7 @@ describe('lintFlowPatterns — unbounded bulk write (#5482)', () => {
17511751
const [f] = lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', multi: true }));
17521752
// Cross-naming, not duplication: the run-time guard refuses "a condition you
17531753
// WROTE is gone"; this rule warns "no condition was ever written".
1754-
expect(f.hint).toContain('#3810');
1754+
expect(f.hint).toContain('the run-time erased-condition guard');
17551755
expect(f.hint).toMatch(/REFUSES this node at run time/);
17561756
expect(f.hint).toMatch(/a written condition is gone/);
17571757
expect(f.hint).toMatch(/the filter is empty/);

0 commit comments

Comments
 (0)