Skip to content

Commit 1328902

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16042-analytics-timezone
2 parents 4d4ae51 + 94ecb7e commit 1328902

14 files changed

Lines changed: 1261 additions & 39 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/metadata-protocol": patch
4+
---
5+
6+
docs(runtime,metadata-protocol): correct the `writable` verdict's illustration — the scope-less booted row is a marketplace / offline import, never a multi-package artifact's module (#14803)
7+
8+
Comment and prose only. No predicate, no assertion and no served shape changes;
9+
every pin behind the `writable` verdict stays green as written.
10+
11+
The `writable` verdict shipped in 17.3.0 with a **false attribution** in its own
12+
explanation, and this corrects it at every site that repeated it. The claim was
13+
that the scope-less booted row `isWritablePackage` answers `false` for is *the
14+
`type: module` sub-package a multi-package artifact carries*. It is not, and it
15+
never was:
16+
17+
- `defineStack` parses every `packages[]` entry through `ManifestSchema`
18+
(`spec/src/stack.zod.ts`, `ArtifactPackageEntrySchema`), whose `scope` is
19+
`.default('project')` (`spec/src/kernel/manifest.zod.ts`), so **no** package of
20+
a compiled artifact is ever scope-less — `dist/objectstack.json` and both
21+
served rows carry `scope: "project"`.
22+
- A genuinely scope-less row arises only where a manifest reaches the registry
23+
**without** that parse, because `installPackage` stores a key-by-key copy that
24+
applies no defaults: a marketplace install / offline file import
25+
(`manifestService.register(rawBody)` to `ql.registerApp`) for the **booted,
26+
read-only** half, and `POST /api/v1/packages` (`body.manifest || body` to
27+
`installPackage`) for the **database base, writable** half.
28+
29+
Measured: `ManifestSchema.parse` of the `app-multi-package` orders body turns an
30+
unauthored `scope` into `scope: "project"`, while `SchemaRegistry.installPackage`
31+
of the same unparsed body yields a record with no `scope` key at all.
32+
33+
What stays, because it is true and load-bearing: a scope-less **booted** package
34+
is read-only while a scope-less **database base** is writable, and only
35+
`engine.manifests` tells them apart — which is why the server owns the verdict.

.github/workflows/lint.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2325,6 +2325,32 @@ jobs:
23252325
# PR. The checklist is a QA ledger, not a code gate; keeping it out of the
23262326
# per-PR path means an unrelated PR is never blocked by checklist drift.
23272327
# Run it by hand: `pnpm check:platform-checklist`. See that dir's README.
2328+
# Its red does have a reporting channel now, and it is NOT this file:
2329+
# `.github/workflows/platform-checklist-watchdog.yml` runs the gate on `main`
2330+
# daily and files or refreshes one issue when it is red.
2331+
2332+
# ⛔ …and the step below is NOT that wiring. What runs here is the STATIC PIN
2333+
# over the watchdog workflow: it asserts the file exists, fires on
2334+
# `schedule:` + `workflow_dispatch:`, carries NO `merge_group:` /
2335+
# `pull_request_target:` trigger, that any `pull_request:` trigger is
2336+
# `paths`-filtered to the watchdog file ALONE, that no board write is
2337+
# reachable from a pull_request run, and that the gate is invoked through its
2338+
# package script rather than an inlined copy. Two file reads, no socket.
2339+
#
2340+
# The watchdog's own `pull_request:` trigger names only itself, so it fires on
2341+
# no unrelated PR — that filter is what keeps the decision above intact, and
2342+
# pinning it is most of this step's job. It has to live HERE rather than
2343+
# inside the watchdog for the other half: a PR DELETING the watchdog would
2344+
# silence any check that lived inside it, and this job is the only place where
2345+
# the absence of that file is observable before it merges.
2346+
#
2347+
# Invoked as `node scripts/…` rather than a `pnpm check:*` alias: see the GATE
2348+
# INVOCATION IDIOM note at the top of this file. The `--self-test` leg runs
2349+
# first because a checker whose own cases failed has no verdict worth printing.
2350+
- name: Platform-checklist watchdog workflow pin
2351+
run: |
2352+
node scripts/check-platform-checklist-watchdog.mjs --self-test
2353+
node scripts/check-platform-checklist-watchdog.mjs
23282354
23292355
# #3280/#3290 org-identifier guard: `organizationId` is the blessed
23302356
# developer-facing name for the caller's active org in hook/action bodies;

.github/workflows/platform-checklist-watchdog.yml

Lines changed: 385 additions & 0 deletions
Large diffs are not rendered by default.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ The largest single consumer — **17 of the 105 sites**.
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161161
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5231`, `:6657`, `:6905`, `:7336`, `:7529` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163-
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:535`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
163+
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:543`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
165165
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` |
166166
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:241`, `:274` |

docs/qa/platform-checklist/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@ Why this shape:
193193
census and not a verdict). Adding anchors never reds; ⛔ lowering a floor is
194194
MAINTAINER-ONLY. ⚠️ Note what a red here reaches: `check:platform-checklist` is not
195195
CI-wired (see "Operating cadence"), so it reaches whoever runs the gate — **not every
196-
PR**. #11730 holds that gap.
196+
PR** — plus the daily `platform-checklist-watchdog` workflow, which files or refreshes
197+
one issue when the gate is red.
197198
- **`why` is the debt marker.** A recipe exists because stock fixtures cannot demonstrate
198199
something — the same discipline as a coverage waiver. Landing the fixture in the
199200
showcase seeds proper retires the recipe; until then `why` says what is missing and
@@ -352,6 +353,12 @@ checklist drift. It runs on a **manual / periodic cadence** instead. Run
352353
dangling id or a forgotten `revision` bump in your own edit;
353354
- **alongside a `checklist-author`** (find gaps) **or `checklist-test`** (execute items).
354355

356+
A red gate does have a reporting channel, and it is not this cadence:
357+
[`.github/workflows/platform-checklist-watchdog.yml`](../../../.github/workflows/platform-checklist-watchdog.yml)
358+
runs `pnpm check:platform-checklist` on `main` daily and files or refreshes one issue when
359+
it is red (⛔ and does nothing when it is green). That closes the visibility half only —
360+
the cadence above is still how the gate gets run *before* a red can matter.
361+
355362
The trade-off of staying out of CI: a new capability kind or enum value that lands on
356363
`main` between runs is caught at the **next** manual run, not the moment it merged. The
357364
ratchets still detect it — they just aren't a blocking gate. If drift-catching latency

docs/qa/platform-checklist/RUNNER.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ build/runtime model incl. the vendored-console staleness trap (§2), and the
1111
anti-false-positive rule (§3). This file assumes it and adds the checklist-specific
1212
contract.
1313

14+
**Who sees a red gate.** `pnpm check:platform-checklist` is not wired into per-PR CI (a
15+
standing maintainer decision — see the README's "Operating cadence"), so the channel that
16+
sees its red is
17+
[`.github/workflows/platform-checklist-watchdog.yml`](../../../.github/workflows/platform-checklist-watchdog.yml):
18+
it runs the gate on `main` daily, files or refreshes exactly one issue when the gate is red,
19+
and does nothing at all when it is green.
20+
1421
## Verdicts
1522

1623
Per **clause** (each acceptance entry gets exactly one):

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
"check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs",
8989
"check:adr-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs",
9090
"check:scripts-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs",
91-
"check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs",
91+
"check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs --self-test && node scripts/check-platform-checklist.mjs",
9292
"check:org-identifier": "node scripts/check-org-identifier.mjs --self-test && node scripts/check-org-identifier.mjs",
9393
"check:runner-env-posture": "node scripts/check-runner-env-posture.mjs --self-test && node scripts/check-runner-env-posture.mjs",
9494
"check:cli-test-child-env": "node scripts/check-cli-test-child-env.mjs --self-test && node scripts/check-cli-test-child-env.mjs",

packages/metadata-protocol/src/get-meta-items-package-writable.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@
1010
* enforce — #8146's "one answer to 'is this package writable?'" applied to the
1111
* read side. It reads `engine.manifests` FIRST: a package booted from an
1212
* artifact through `registerApp` is read-only whatever its scope says, and a
13-
* scope-less `type: module` carried by a multi-package artifact lands there
14-
* too, while a scope-less Studio-created base does not. Only the server holds
13+
* scope-less BOOTED package — a marketplace install / offline file import,
14+
* which reaches the registry with no `ManifestSchema` parse — lands there
15+
* too, while a scope-less Studio-created base (`POST /api/v1/packages`) does
16+
* not. ⛔ Neither is a module carried by a multi-package artifact:
17+
* `defineStack` parses every `packages[]` entry through `ManifestSchema`, whose
18+
* `scope` is `.default('project')`, so no package of a compiled artifact is
19+
* ever scope-less. Only the server holds
1520
* `engine.manifests`, which is why the client could never derive this.
1621
*
1722
* The engine is the same shape `meta-overlay-cache.test.ts` drives: the
@@ -24,7 +29,7 @@ import { ObjectStackProtocolImplementation } from './protocol.js';
2429

2530
/** Booted code package, explicit `scope: 'project'`. */
2631
const CODE_PROJECT = 'app.acme.crm';
27-
/** Booted, SCOPE-LESS module — the multi-package-artifact sub-package. */
32+
/** Booted and SCOPE-LESS — a marketplace / offline import, registered unparsed. */
2833
const CODE_MODULE = 'app.acme.crm.billing';
2934
/** Platform / marketplace delivered. */
3035
const SYSTEM_SCOPED = 'com.objectstack.platform';
@@ -47,7 +52,9 @@ function make() {
4752
row(DB_BASE),
4853
];
4954
const byId = new Map(records.map((r) => [r.manifest.id as string, r]));
50-
// What `ObjectQL.registerApp` records for every package of a loaded artifact.
55+
// What `ObjectQL.registerApp` records — for every package of a loaded artifact
56+
// (CODE_PROJECT, parsed and therefore `scope: 'project'`) and for a
57+
// marketplace / offline import (CODE_MODULE, unparsed and therefore scope-less).
5158
const manifests = new Map<string, unknown>([
5259
[CODE_PROJECT, byId.get(CODE_PROJECT)!.manifest],
5360
[CODE_MODULE, byId.get(CODE_MODULE)!.manifest],

packages/metadata-protocol/src/protocol.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6797,23 +6797,53 @@ export class ObjectStackProtocolImplementation implements
67976797
// caller that already gates therefore sees no change, provided it gated
67986798
// on the same type this line gates on. Every such caller does:
67996799
//
6800-
// • `packages/rest`'s `GET /meta/:type` list door — the only door
6801-
// that both gates and reaches this method — computes
6800+
// • `packages/rest`'s `GET /meta/:type` list door — one of TWO doors
6801+
// that both gate and reach this method — computes
68026802
// `organizationIdForMetaRead(canonicalMetaUrlType(req.params.type),
68036803
// ctx?.tenantId)` and then passes `type: req.params.type`, the RAW
68046804
// segment. The first statement of this method folds that segment
68056805
// through {@link canonicalizeMetaRequestType}, which IS
68066806
// `canonicalMetaUrlType` — so `request.type` here is the identical
68076807
// STRING the door gated on, and the second application is the
68086808
// algebraic no-op above.
6809+
// • `GET /meta/diagnostics?type=` — the SECOND such door, and the one
6810+
// a caller-side grep cannot see. It gates the same way
6811+
// (`organizationIdForMetaRead(canonicalMetaUrlType(diagnosticsType),
6812+
// ctx?.tenantId)`) and then passes its own RAW segment on — but it
6813+
// reaches this method TRANSITIVELY, through
6814+
// {@link getMetaDiagnostics}, whose `?type=` arm sets `targetTypes =
6815+
// [request.type]` and loops `getMetaItems({ type: t,
6816+
// organizationId, … })` over it. So the door's segment still arrives
6817+
// here as `request.type` and is still folded by the same first
6818+
// statement: the identical STRING, the same algebraic no-op, one hop
6819+
// further out. ⚠️ That hop is UNDECLARED — `getMetaDiagnostics` is
6820+
// not a member of `MetadataProtocol`, neither required nor optional,
6821+
// so the door reaches it through a `(p as any)` cast behind a 501
6822+
// feature-detect. Real at runtime, invisible to the type system, and
6823+
// therefore something a caller census must be TOLD rather than left
6824+
// to derive.
68096825
// • the search sweep's page read below gates on `'page'` and passes
68106826
// `'page'`; `page` is non-overridable, so both readings are
68116827
// `undefined` whatever the session holds.
68126828
// • the four remaining `organizationIdForMetaRead` call sites in
68136829
// `rest-server.ts` (`/layers`, the by-name read, `/history`,
68146830
// `/diff`) reach `getMetaItemLayered` / `getMetaItem` /
6815-
// `historyMetaItem` / `diffMetaItem` — never this method — so this
6816-
// line cannot move them at all.
6831+
// `historyMetaItem` / `diffMetaItem` — never this method, at any
6832+
// depth — so this line cannot move them at all. Two doors named
6833+
// above plus these four IS that file's whole set of SIX; the
6834+
// enumeration that named one door and "four remaining" described
6835+
// five, and the door it dropped was the one that reaches here.
6836+
//
6837+
// ⭐ Read this list from the CALLEE side, which is how it is now built.
6838+
// A grep for doors that invoke `getMetaItems` answers only its own
6839+
// question: it cannot see a door that arrives through something else,
6840+
// and that is exactly how the diagnostics door went unlisted. The
6841+
// closed form is the other direction — `this.getMetaItems(` has THREE
6842+
// callers in this file: {@link getMetaDiagnostics},
6843+
// {@link searchAll} and {@link findReferencesToMeta}. The third gates
6844+
// nothing, deliberately: its door spends the organization on the
6845+
// reference SOURCES while `req.params.type` is the TARGET, so it hands
6846+
// the tenant over RAW and is not a caller this paragraph is about.
68176847
//
68186848
// ⛔ Gate AFTER the fold, never before it. `declaresOrgOverride`
68196849
// tolerates the MANIFEST plurals and not the URL-only ones
@@ -7290,9 +7320,14 @@ export class ObjectStackProtocolImplementation implements
72907320
// package switcher reads that list; it used to derive "writable"
72917321
// client-side from `manifest.scope` alone, which is not this server's
72927322
// rule — ADR-0070 D2 (`isWritablePackage`) reads `engine.manifests`
7293-
// FIRST, so a scope-less module booted from a multi-package artifact is
7294-
// read-only while a scope-less Studio-created base is writable, and only
7295-
// the server can tell the two apart. Same predicate the authoring and
7323+
// FIRST, so a scope-less BOOTED package — a marketplace install / offline
7324+
// file import, which reaches the registry through `ql.registerApp` with no
7325+
// `ManifestSchema` parse — is read-only while a scope-less Studio-created
7326+
// base (`POST /api/v1/packages`) is writable, and only the server can tell
7327+
// the two apart. ⛔ Neither is a module carried by a multi-package
7328+
// artifact: `defineStack` parses every `packages[]` entry through
7329+
// `ManifestSchema`, whose `scope` is `.default('project')`, so no package
7330+
// of a compiled artifact is ever scope-less. Same predicate the authoring and
72967331
// lifecycle gates use (#8146: one answer), computed on a spread COPY:
72977332
// the registry record is never mutated and the verdict is never stored.
72987333
// The runtime dispatcher door decorates its own read of the same

0 commit comments

Comments
 (0)