Skip to content

Commit 204aa2e

Browse files
committed
Merge origin/main into claude/issue-22870-p2-service-mailbox
system-context.mdx: main's page taken, then the inbound-mail gates' row re-inserted as row 47 and the later rows, the prose references and the census ledger re-derived by the census gate (gen:system-context-census). Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y Co-authored-by: Claude <noreply@anthropic.com>
2 parents a001642 + df7a137 commit 204aa2e

39 files changed

Lines changed: 2914 additions & 165 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
Under a walled posture the `/packages` verbs that publish, discard, roll back, revert, duplicate or adopt security catalog items are the platform operator's
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: registered walled-catalog-package-verbs-retired -->
11+
12+
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant").
13+
14+
**Why.** A package's positions and permission sets are environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack: the operator published a permission set in a writable package, and another organization's member held it. An organization owner granted `manage_metadata` inside their own organization (never the platform operator) then:
15+
- published the operator's pending draft adding `manage_users`, and that member's next resolution carried it;
16+
- rolled the package back, and the member lost it;
17+
- reverted the operator's commit, and the member lost it;
18+
- copied the set, under the same name, into a package of their own, and `GET /meta` then served their copy;
19+
- discarded the operator's pending draft (through `revert`, and `discard-drafts` is the same discard by a second door);
20+
- adopted the environment's catalog rows into their own package.
21+
22+
**FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner). Every door is on the runtime dispatcher, and each **FROM** is: admitted to any `manage_metadata` holder. Each **TO** is `403 PERMISSION_DENIED`, naming the posture, before the verb runs:
23+
24+
- `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position, permission-set or capability draft.
25+
- `POST /api/v1/packages/:id/revert`, when the package has such a draft.
26+
- `POST /api/v1/packages/:id/discard-drafts`, when the package has such a draft.
27+
- `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position, permission set or capability.
28+
- `POST /api/v1/packages/:id/commits/:commitId/revert`, when that commit touched one. The commit's own items are judged: the verb takes the commit id alone, so the URL's package segment changes nothing.
29+
- `POST /api/v1/packages/:id/duplicate`, when the source package's published environment rows (the ones it copies) hold one.
30+
- `POST /api/v1/packages/:id/adopt-orphans`, when the environment's orphaned rows include one.
31+
32+
**Unchanged.** A package whose drafts, commits or rows carry no catalog item is handled as before, for the same callers. The platform operator runs every verb as before. Under `single` (and with no `tenancy` service) nothing changes, and no extra read happens: the verdict is asked before the items are read.
33+
34+
**The remedy.** The platform operator publishes, discards, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/verify': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
feat(verify): the delete-behaviour family — every `deleteBehavior` a reference field declares is what a delete of the referenced record does, runnable as `os verify --delete-behaviors` (#15953)
7+
8+
Clause-②: yes
9+
10+
A fourth derived proof family beside `runCrudVerification`, `runRlsProofs` and `runReferenceShapeProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`) and reads what the declaration states through the spec's own parse of the field (`FieldSchema`), which is the value a built artifact carries: a `lookup` or `tree` that authors nothing states `set_null`, an authored value is kept, and a bare `master_detail` states none. For each field it creates a fresh target record and a holder referencing it, deletes the target by id, and reads back what the engine left, through two doors: the REST data route as the signed-in caller, and the engine's own delete as the system principal.
11+
12+
- `cascade` holds when the holder is gone with the target.
13+
- `set_null` holds when the holder survives and no longer references the deleted record.
14+
- `restrict` holds when the delete is refused with `DELETE_RESTRICTED` naming the holder's object, and both records are still there.
15+
- In every case a second holder, referencing another record of the same target object, must be untouched.
16+
17+
A refusal for any other reason (a hook's veto, a validation rule, a permission) is `unattributed`, so an engine that refused every delete would not read as a proven `restrict`. A field is `proven`, `contradicted` (a delete did something else), `failed` (a door faulted), `inconclusive` (an unattributed refusal, or a fixture the app refused; the report says why), `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`.
18+
19+
Two shapes are `excluded` rather than given an expectation, because an engine rule decides what a delete does to them, not the declaration: a `set_null` on a required reference (it cannot be cleared), and a field whose parse carries no `deleteBehavior` (a bare `master_detail`, a `user`). A field is `not-derivable` when no fixture can be built from the app's metadata: its target is not an object the app declares (such as `sys_user`), it is `readonly` or `system`, or the CRUD derivation cannot synthesize its holder or target. Only by-id deletes are asked; a predicate or multi-row delete is outside the family.
20+
21+
**`@objectstack/verify` — new exports.**
22+
23+
- `runDeleteBehaviorProofs(stack, token, config, { doors? })` → `DeleteBehaviorReport`. Run it on a fresh stack of its own: every `restrict` probe leaves its records behind.
24+
- `deriveDeleteBehaviorCases(config)`, `formatDeleteBehaviorReport(report)`.
25+
- Types: `DeclaredDeleteBehavior`, `DeleteBehaviorCase`, `DeleteBehaviorDoor`, `DeleteBehaviorAnswer`, `DeleteBehaviorProbeResult`, `DeleteBehaviorFieldStatus`, `DeleteBehaviorFieldResult`, `DeleteBehaviorReport`, `DeleteBehaviorProofOptions`.
26+
27+
```ts
28+
import { bootStack, runDeleteBehaviorProofs, formatDeleteBehaviorReport } from '@objectstack/verify';
29+
30+
const stack = await bootStack(myApp);
31+
const report = await runDeleteBehaviorProofs(stack, await stack.signIn(), myApp);
32+
console.log(formatDeleteBehaviorReport(report));
33+
expect(report.summary.hardFailures).toBe(0);
34+
await stack.stop();
35+
```
36+
37+
**`@objectstack/cli` — `os verify --delete-behaviors`.** Opt-in, like `--reference-shapes`, on a fresh boot of its own. A contradicted declaration, or a door that faulted, counts in `hardFailures` and fails the run. A `tree` field's delete is not honoured today (deleting the parent leaves the child's reference in place), so an app that declares one fails the run with the flag. The `--json` document carries the report as `deleteBehaviors` when the flag is given; without the flag the run and its document are unchanged.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata': patch
4+
---
5+
6+
feat(spec)!: `defineStack` refuses two entries of one named collection under one identity, naming the collection, the identity and every index — for 27 collections (#22934)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered stack-collection-duplicate-entry-refused -->
11+
12+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
The runtime registers the entries of these collections under one identity each, and a stack could author two entries under one identity with nothing refusing it: `defineStack` and `objectstack validate` accepted the pair (exit 0, `valid: true`, no finding naming it). Measured on a source boot (`objectstack serve`, no build) and on `objectstack build` followed by an artifact-backed boot:
15+
16+
- `objects`, `pages`, `dashboards`, `reports`, `datasets`, `flows`, `jobs`, `emailTemplates`, `books`, `positions`, `permissions`, `capabilities`, `sharingRules`, `webhooks`, `mappings`, `analyticsCubes`, `connectors`, `picklists`, `tools`, `skills`, `views`, `apis`, `apps` and `objectExtensions` kept only the LAST entry on both paths, with no notice at the default log level (for `apis`, the first entry's path answered 404);
17+
- `datasources`: the metadata described the last entry while the driver connected with the first one's configuration;
18+
- `hooks`: the metadata listed one hook while both handlers were bound and ran;
19+
- `docs`: `objectstack validate` and `objectstack build` refused the pair (`docs/duplicate-name`), while a source boot kept the last.
20+
21+
**What is refused.** `defineStack`'s cross-reference pass refuses the stack with its existing envelope (`STACK_CROSS_REFERENCE_INVALID`, status 422, `defineStack cross-reference validation failed (N issues):`), one finding per collision, naming the collection, the identity and every index:
22+
23+
```text
24+
✗ Datasource 'crm_external' is declared twice in `datasources`: stack.datasources[0] and stack.datasources[1]. …
25+
```
26+
27+
The identity is what the runtime keys an entry by: its `name`; the `(name, locale)` pair for `emailTemplates` (one name across several locales is an i18n bundle, and an entry with no `locale` counts as `en-US`); the extended object (`extend`) for `objectExtensions`; and for `views` the object a container binds to (`object`, else `list.data.object`, else `form.data.object`, else its `name`). The check runs in an object-less stack too, and every entry counts, byte-identical twins included. `objectstack validate`, `objectstack build` and a source boot all load the config through `defineStack`, so all three refuse it at load. Like every cross-reference check it is skipped under `defineStack(…, { strict: false })`. On an `app` package, two apps under one name now get this refusal, which runs before the single-app count.
28+
29+
**Unchanged.** Distinct identities; an email-template i18n bundle (one name, one entry per locale); the map form under distinct keys (two keys whose entries spell one explicit `name` are one identity, and are refused); the same name reused by another package of a multi-package project (`composeStacks(…, { manifest: 'preserve' })` keeps each package's collections in its own `packages[i]` body, which this check does not read); `plugins` (last-one-wins with a warning is the kernel's declared contract), `agents` (withdrawn for app packages) and `picklistExtensions` (merged by option value); and the existing `actions` and `functions` refusals.
30+
31+
## FROM → TO
32+
33+
| you wrote | write instead |
34+
|:--|:--|
35+
| `pages: [{ name: 'home', … }, { name: 'home', … }]` (any of the 24 name-keyed collections) | one entry per name: rename one (`home_admin`) and every reference that meant it, or merge the two into one entry |
36+
| `emailTemplates: [{ name: 'acme.welcome', … }, { name: 'acme.welcome', locale: 'en-US', … }]` | one entry per name and locale: give the second its own `locale` (`zh-CN`), or merge the two |
37+
| `datasources: [{ name: 'ext', config: a }, { name: 'ext', config: b }]` | one datasource per name — the driver used to connect `a` while the metadata described `b` |
38+
| `hooks: [{ name: 'audit', object: 'a', … }, { name: 'audit', object: 'b', … }]` | one hook with `object: ['a', 'b']`, or two hooks with two names |
39+
| `apis: [{ name: 'feed', path: '/api/v1/apps/x/a' }, { name: 'feed', path: '/api/v1/apps/x/b' }]` | give each endpoint its own name |
40+
| `views: [defineView({ object: 'task', list }), defineView({ object: 'task', form })]` | one container per object: `defineView({ object: 'task', list, form })` (named views go in `listViews` / `formViews`) |
41+
| `objectExtensions: [{ extend: 'task', fields: { a } }, { extend: 'task', fields: { b } }]` | one extension per target: `{ extend: 'task', fields: { a, b } }` |
42+
43+
**The one-line fix: give each entry of a collection its own identity, or merge the duplicates into one entry.**
44+
45+
No D2 conversion rewrites it: which entry keeps the identity, and what the other one becomes, is the author's call. Its D3 record is the semantic entry `stack-collection-duplicate-entry-refused` (protocol 18).
46+
47+
**`@objectstack/metadata`.** `deriveViewContainerObject` — the "which object does a `views` container bind to" derivation the registrars file containers by — moved into `@objectstack/spec` (exported from its root and `./ui`), because the stack contract now judges the same slot. `@objectstack/metadata` re-exports that one declaration under the same name, from its root and from `@objectstack/metadata/view-container`; behaviour is unchanged. That subpath used to import nothing and now loads the `@objectstack/spec` root entry, which every in-repo importer of it already loads.
48+
49+
**Who is affected, measured.** The four example apps (`examples/app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) carry no duplicate under these identities. Deployed metadata and other repositories were not measured.

‎.github/workflows/lint.yml‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7019,8 +7019,76 @@ jobs:
70197019
# comment): the nested group dirs and apps/ (docs) need their own globs.
70207020
# Examples and the downstream contract keep their dedicated steps below —
70217021
# they are distinct gates, not bulk coverage.
7022+
#
7023+
# ── WHICH PACKAGES, PER EVENT (#23017) ──
7024+
# The three filters are the UNIVERSE. `schedule`, `push`,
7025+
# `workflow_dispatch` and any other event type-check all of it (the full
7026+
# battery, #22998). `pull_request` / `merge_group` type-check what their
7027+
# diff affects, plus dependents, through ci.yml Test Core's selector
7028+
# (same diff base, same cross-package test union, same fall-back to the
7029+
# full list), intersected with the universe. Not the cache alone: the
7030+
# restore above already reaches main's seed, but nearly every package
7031+
# depends on spec, so one spec commit between seed and run moves nearly
7032+
# every typecheck hash (PR run 38239402273 replayed 138 of 139 tasks; its
7033+
# merge group 38242582445, 0 of 74 typecheck tasks).
7034+
# ⛔ A changed repo-root file (Markdown excepted), or a `scripts/` file a
7035+
# package.json script runs, means the whole universe: `turbo ls
7036+
# --affected` selects ZERO packages for a root package.json,
7037+
# pnpm-workspace.yaml, .npmrc or root-importer lockfile change (turbo
7038+
# 2.11.5). ⛔ An empty selection exits here: `turbo run typecheck` with no
7039+
# `--filter` runs the whole workspace. Selection decides which packages
7040+
# run; the package-local turbo cache still decides which execute.
70227041
- name: Type check workspace packages
7023-
run: pnpm exec turbo run typecheck --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*'
7042+
env:
7043+
OS_SHARD_EVENT_NAME: ${{ github.event_name }}
7044+
OS_SHARD_PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
7045+
OS_SHARD_PR_PINNED_BASE_SHA: ${{ github.event.pull_request.base.sha }}
7046+
OS_SHARD_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
7047+
run: |
7048+
set -euo pipefail
7049+
UNIVERSE=(--filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*')
7050+
WHOLE=''
7051+
case "$OS_SHARD_EVENT_NAME" in
7052+
pull_request|merge_group) ;;
7053+
*) WHOLE="the '$OS_SHARD_EVENT_NAME' event carries the full battery" ;;
7054+
esac
7055+
if [ -z "$WHOLE" ]; then
7056+
rm -f "$RUNNER_TEMP/turbo-ls.json" "$RUNNER_TEMP/changed-files.txt"
7057+
bash scripts/ci/select-shard-packages.sh
7058+
if [ ! -s "$RUNNER_TEMP/changed-files.txt" ]; then
7059+
WHOLE='the selector left no changed-file list (missing or empty), so the root-level rule cannot be judged'
7060+
else
7061+
{ git grep -h -o -E '(\.\./)+scripts/[A-Za-z0-9._/-]+' -- \
7062+
'packages/*/package.json' 'packages/*/*/package.json' 'apps/*/package.json' || true; } \
7063+
| sed -E 's#^(\.\./)+##' | sort -u > "$RUNNER_TEMP/package-script-tools.txt"
7064+
ROOT_FILE=$(grep -m1 -v -e / -e '\.md$' "$RUNNER_TEMP/changed-files.txt" || true)
7065+
TOOL_FILE=$(grep -m1 -Fx -f "$RUNNER_TEMP/package-script-tools.txt" "$RUNNER_TEMP/changed-files.txt" || true)
7066+
if [ -n "$ROOT_FILE" ]; then
7067+
WHOLE="root-level change: $ROOT_FILE"
7068+
elif [ -n "$TOOL_FILE" ]; then
7069+
WHOLE="a script the packages' own package.json scripts run changed: $TOOL_FILE"
7070+
fi
7071+
fi
7072+
fi
7073+
if [ -n "$WHOLE" ]; then
7074+
echo "Type-checking the whole workspace -- $WHOLE."
7075+
pnpm exec turbo run typecheck "${UNIVERSE[@]}"
7076+
exit 0
7077+
fi
7078+
pnpm exec turbo ls "${UNIVERSE[@]}" --output=json > "$RUNNER_TEMP/typecheck-universe.json"
7079+
jq -r --slurpfile sel "$RUNNER_TEMP/turbo-ls.json" \
7080+
'($sel[0].packages.items | map(.name)) as $s | .packages.items[].name | select(. as $n | any($s[]; . == $n))' \
7081+
"$RUNNER_TEMP/typecheck-universe.json" > "$RUNNER_TEMP/typecheck-selected.txt"
7082+
mapfile -t SELECTED < "$RUNNER_TEMP/typecheck-selected.txt"
7083+
if [ "${#SELECTED[@]}" -eq 0 ]; then
7084+
echo "No workspace package is affected by this diff -- nothing to type-check."
7085+
exit 0
7086+
fi
7087+
echo "Type-checking ${#SELECTED[@]} affected package(s) of the universe:"
7088+
printf ' %s\n' "${SELECTED[@]}"
7089+
FILTERS=()
7090+
for P in "${SELECTED[@]}"; do FILTERS+=("--filter=$P"); done
7091+
pnpm exec turbo run typecheck "${FILTERS[@]}"
70247092
70257093
# Seed the shared Turbo cache from main only (see the restore step above).
70267094
#

‎content/docs/api/metadata-api.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,19 @@ Uninstall a package. Under a `group` or `isolated` tenancy posture, a package
127127
holding positions or permission sets is the platform operator's to delete: any
128128
other caller is refused `403 PERMISSION_DENIED` (ADR-0131 D3).
129129

130+
The package's other write verbs follow the same rule under a wall, judged by
131+
the items each one acts on:
132+
- `POST /packages/:id/publish-drafts`, `POST /packages/:id/discard-drafts` and
133+
`POST /packages/:id/revert`, by the package's pending drafts;
134+
- `POST /packages/:id/rollback`, by the commits it would undo;
135+
- `POST /packages/:id/commits/:commitId/revert`, by that commit's own items,
136+
whatever package the URL names;
137+
- `POST /packages/:id/duplicate`, by the source's published items it copies;
138+
- `POST /packages/:id/adopt-orphans`, by the environment's orphaned rows.
139+
140+
When those items include a position, a permission set or a capability, only the platform
141+
operator may run the verb.
142+
130143
### `PATCH /packages/:id/enable`
131144

132145
Enable a disabled package.

0 commit comments

Comments
 (0)