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
10 changes: 10 additions & 0 deletions .changeset/33374-skills-teach-write-requires-master-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/skills': patch
---

docs(skills): the `controlled_by_parent` authoring guidance teaches `sharing.controlledByParent.writeRequiresMasterRead`

Clause-②: no
Metadata-protocol: none

The data skill's relationship rules said every write of a `controlled_by_parent` detail needs edit on its master, with `ownerMayEdit` as the only opt-in. They now teach the second opt-in as well: with `writeRequiresMasterRead: true` on the detail, whoever can read the master inserts, updates and deletes detail rows under the detail's own object permissions, row-level security and field-level security, on re-points and on each opted-in chain hop too; an on-behalf-of write needs both users to read the master, and the platform ownership floor still binds predicate and on-behalf-of updates and deletes. The rules name its parse refusals (beside `ownerMayEdit: true`, off a `controlled_by_parent` object, one level up) and that `security-controlled-by-parent-owner-policy-without-opt-in` stays silent on such a detail. Without the key every write still needs edit on the master. The optional-master paragraph says setting or emptying a master asks read under the opt-in. One eval prompt covers the opt-in. The lookup and junction examples drop their boilerplate parent objects to pay for the new text inside the file's token ceiling.
17 changes: 17 additions & 0 deletions skills/objectstack-data/evals/objects-fields-relationships.json
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,23 @@
"class: 'telemetry'"
]
}
},
{
"id": 17,
"prompt": "Order lines belong to orders. Anyone who can see an order should be able to add, change or remove its lines, as far as the line object's own permissions allow; nobody needs edit rights on the order itself for that. How do I declare the line's sharing?",
"expected_output": "`order_line` is `sharingModel: 'controlled_by_parent'` with a required `master_detail` field to `order`, plus `sharing: { controlledByParent: { writeRequiresMasterRead: true } }`: read access to the order becomes the master question for every write (insert, update, delete, re-point), and the line's own object permissions, RLS and FLS decide the rest. Without the key every line write needs edit on the order. Not `ownerMayEdit` (only the owner's by-id update, and refused beside the new key), not the key one level up (`sharing.writeRequiresMasterRead`, refused), and not on a non-`controlled_by_parent` object (refused). The platform ownership floor still binds predicate and on-behalf-of updates and deletes.",
"files": [],
"assertions": {
"must_contain": [
"controlled_by_parent",
"master_detail",
"controlledByParent",
"writeRequiresMasterRead: true"
],
"must_not_contain": [
"sharing: { writeRequiresMasterRead"
]
}
}
]
}
60 changes: 20 additions & 40 deletions skills/objectstack-data/rules/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,15 @@
| Type | Lifecycle | Required | Sharing | Roll-ups | Use Case |
|:-----|:----------|:---------|:--------|:---------|:---------|
| `lookup` | Independent | Optional by default | Independent | Supported via `summary` | "Related to" |
| `master_detail` | Coupled (cascade delete) | Forced only under `controlled_by_parent`, unless the optional master (below); else lint-warned | Inherits parent; owner opt-in `ownerMayEdit` (below) | Supported via `summary` | "Owned by" |
| `master_detail` | Coupled (cascade delete) | Forced only under `controlled_by_parent`, unless the optional master (below); else lint-warned | Inherits parent; read-on-master opt-ins (below) | Supported via `summary` | "Owned by" |
| `tree` | Self-reference | Optional — and so is `reference`, which when written must name this object itself; any other object is refused at parse, not lint-warned | N/A | Not available | Hierarchical |

## Patterns

### One-to-Many: lookup

```typescript
// Parent: Account
export default ObjectSchema.create({
name: 'account',
sharingModel: 'public_read_write',
fields: {
name: { type: 'text', required: true },
}
});

// Child: Contact (independent lifecycle)
// Child: Contact (independent lifecycle) of an account
export default ObjectSchema.create({
name: 'contact',
sharingModel: 'public_read_write',
Expand Down Expand Up @@ -80,22 +71,28 @@ export default ObjectSchema.create({
});
```

#### Owner edits under `controlled_by_parent` — `ownerMayEdit`
#### Writes under `controlled_by_parent` — two opt-ins

A `controlled_by_parent` detail row naming a master inherits its access: reads are scoped
to accessible masters and **every write needs edit on the master**. To
let a detail row's owner (`owner_id`) update that row with only *read* access
to the master, opt in on the detail:
to accessible masters and, by default, **every write needs edit on the master**.
An opt-in on the detail asks *read* on it instead:

```typescript
sharingModel: 'controlled_by_parent',
sharing: { controlledByParent: { ownerMayEdit: true } }, // by-id update only
sharing: { controlledByParent: { writeRequiresMasterRead: true } }, // or ownerMayEdit
```

Nothing else widens — insert, delete, predicate (`multi: true`) updates and
on-behalf-of writes under a master still need its edit; the detail's own row policies
still AND in. Refused at parse under any other `sharingModel` (the owner
already writes there) and one level up (`sharing.ownerMayEdit`).
- `ownerMayEdit: true` widens only the row owner's (`owner_id`) by-id update; insert,
delete, predicate (`multi: true`) updates and on-behalf-of writes still need master edit.
- `writeRequiresMasterRead: true`: whoever reads the master inserts, updates and
deletes the detail under its own object permissions, RLS and FLS (re-points and
each opted-in chain hop too; on-behalf-of, both users must read the master). The
ownership floor (rows you created) still binds predicate and on-behalf-of updates
and deletes. Refused beside `ownerMayEdit: true`, which it covers. Lint
`security-controlled-by-parent-owner-policy-without-opt-in` is silent here.

The detail's own row policies AND in under both. Each is refused at parse under any
other `sharingModel` and one level up (`sharing.KEY`).

#### Optional master — a detail row with no master

Expand All @@ -110,33 +107,16 @@ account: { type: 'master_detail', reference: 'account', required: false },
A row with no master is its owner's (`owner_id`): the owner or View All on the
detail reads it, the owner or Modify All writes it (no owner: View All / Modify
All only). An insert naming no master is admitted. Setting a master asks edit
on it; emptying one asks edit on the stored master, even under `ownerMayEdit`. A
on it, emptying one edit on the stored master (even under `ownerMayEdit`; read
under `writeRequiresMasterRead`). A
chain hop with no master ends there under the same rule. Refused beside a
second `master_detail` or as `readonly`/`system`; `ObjectSchema.create()` reads
an omitted `required` as `true`.

### Many-to-Many: Junction Object

```typescript
// Side A: Project
export default ObjectSchema.create({
name: 'project',
sharingModel: 'private',
fields: {
name: { type: 'text', required: true },
}
});

// Side B: Employee
export default ObjectSchema.create({
name: 'employee',
sharingModel: 'private',
fields: {
name: { type: 'text', required: true },
}
});

// Junction: Project Assignment
// Junction: Project Assignment between project and employee
export default ObjectSchema.create({
name: 'project_assignment',
sharingModel: 'public_read_write',
Expand Down
Loading