Skip to content

Commit 4283069

Browse files
committed
Merge origin/main: rewrite #14's enforcement half onto the retired-gate regime
The per-PR `ADR maintainer approval` check retired on main (2026-08-18 ruling: a human merge IS the review record), `scripts/check-adr-merge-approval.mjs` is deleted, and the register moved to `scripts/pm/check-governed-merges.mjs` — and widened to five surfaces, `AGENTS.md` and `CLAUDE.md` among them. Kept from this branch: the class-rule reframing, the four-copies-to-one consolidation, the §7 / Post-Task Checklist / Skills-section edits. Rewritten: the surface list now quotes the maintainer's own 2026-08-18 definition verbatim, the one-liner resolves against the new register, and the enforcement paragraph states the real regime — discipline is the only pre-merge barrier, detection is the report-only post-merge audit, and CODEOWNERS routes `docs/adr/` alone of the five. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
2 parents ebd2eb2 + b348ac2 commit 4283069

45 files changed

Lines changed: 3897 additions & 2732 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/dogfood": patch
3+
---
4+
5+
docs(qa): record `dispatcher-plugin.ts` as deliberately outside the #2992 transport tripwire set, with the reason (#9410)
6+
7+
`packages/runtime/src/dispatcher-plugin.ts` has both properties that make a file
8+
a plausible landing site for a realtime transport, and neither the conformance
9+
test nor the protocol page said anything about it. It **mounts routes** —
10+
`/actions`, `/automation` and `/packages`, the registration path separate from
11+
the `@objectstack/rest` one — and it **already writes SSE**: two
12+
`text/event-stream` sites with `no-cache` and `keep-alive`, working plumbing an
13+
agent could extend without writing any new transport mechanics. None of the five
14+
`#2992` / ADR-0096 D4 transport tripwires watch it, so a subscribe/fan-out
15+
transport wired there mints no `TRANSPORT-WIRED` key, produces no UNCLASSIFIED
16+
surface and reds no build. The protocol page stated the general limitation ("a
17+
transport wired outside the watched files produces no key and no failure")
18+
without naming the specific already-SSE-capable file sitting inside it.
19+
20+
**This change is a recording. It changes no behaviour**: no probe is added, no
21+
key is minted, and no matrix row is written for the two existing sites.
22+
23+
The reason is recorded because it is the part a reader cannot re-derive cheaply.
24+
Those two `text/event-stream` sites are **per-request AI response streaming, not
25+
realtime subscription fan-out**: each drains one `AsyncIterable` that the route
26+
handler itself returned into that same request's response body and then calls
27+
`res.end()` — the second site's own source comment names its producer as the AI
28+
routes. No subscriber is registered, no event is delivered to a *set* of
29+
recipients, and the file carries no upgrade handler, no subscribe registration
30+
and no realtime-service call. Watching it with the existing mechanics pattern
31+
would therefore mint a key on day one for a surface that is not the hazard
32+
`#2992` is about, leaving only two exits: classify two non-realtime sites in the
33+
matrix vocabulary, or weaken the pattern. Neither is acceptable, so the file
34+
stays out and the boundary is written down instead.
35+
36+
It is written in the two places a reader actually lands. In
37+
`authz-conformance.test.ts` the note closes the tripwire probe list, so a reader
38+
who has just finished enumerating the watched set reads the set's boundary in
39+
the same breath — beside, and explicitly distinguished from, the pre-existing
40+
`#5519` mention of the same file, which is about anonymous gates on the mounted
41+
routes and is a different point. In `realtime-protocol.mdx` it extends the
42+
identity-admission callout at the exact sentence that states the general
43+
limitation.
44+
45+
The exclusion is drawn on **fan-out, not on the SSE content type**, and both
46+
records say so: wiring an upgrade handler, a subscribe registration or a
47+
realtime-service call into that file puts it back inside the hazard while the
48+
recorded boundary still claims otherwise. Promoting it into the tripwire
49+
population with such a fan-out-specific marker is written into #8347's
50+
acceptance as a precondition of the WebSocket/SSE transport landing, so the
51+
design effort is spent when the hazard becomes real rather than now.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): a triggered run carries the flow author's `successMessage` / `errorMessage` — `execute()` and both retry exits, symmetric with `resume()` (#9414)
6+
7+
`AutomationResult` declares `successMessage` / `errorMessage` as a general
8+
terminal-result feature — *"Friendly terminal messages copied from the flow
9+
definition (`flow.successMessage` / `flow.errorMessage`) … `successMessage` is
10+
set on terminal success, `errorMessage` on failure."* One producer honoured it.
11+
`resumeInternal` set both on its terminal returns; `execute()` set neither, on
12+
either exit, and neither did `executeWithoutRetry` or `retryExecution`.
13+
14+
So a flow's own words reached a caller **only if the run happened to pause and
15+
be resumed**. A flow dispatched straight through
16+
`POST /api/v1/automation/:name/trigger` — or the legacy `trigger/:name` that
17+
`client.automation.trigger()` calls — carried nothing, though the flow declared
18+
the text and the contract said it was set. One declaration, two behaviours
19+
decided by *route* rather than by authoring.
20+
21+
**The consumer was already there and already reading.** The trigger route
22+
carries `errorMessage` into `error.details.errorMessage` (#9413), which is the
23+
one place the console reads it from (objectui `flowResponse.ts`, #4899) — and on
24+
the trigger path it was **always absent at the source**, so every non-screen flow
25+
showed the raw node error instead of the sentence its author wrote.
26+
27+
Four terminal exits now produce the pair, which is every exit a triggered run can
28+
leave through:
29+
30+
- `execute()` terminal success → `successMessage`; terminal failure →
31+
`errorMessage`, **beside** the raw `error` rather than instead of it (the
32+
transport folds `error` into the ADR-0112 message and carries the author's text
33+
in `details`).
34+
- `executeWithoutRetry()` — both exits. `retryExecution` returns this result
35+
verbatim when a later attempt succeeds, so without it `successMessage` would
36+
depend on *which attempt* happened to work.
37+
- `retryExecution()`'s **exhausted** exit, which is a different exit from
38+
`execute()`'s own failure return — a flow under `errorHandling.strategy:
39+
'retry'` never reaches that one. A repair stopping at `execute()` would have
40+
left the message missing for exactly the runs most likely to need it.
41+
42+
**Nothing else gained a message, deliberately.** The paused return is not
43+
terminal; the skip exits (`condition_not_met`, `reentrancy_loop_guard`) return
44+
`success: true` for a run that executed no node; the never-dispatched exits
45+
(flow not found / disabled / no start node) have no lifecycle verdict at all and
46+
must not acquire a second channel implying one. Those boundaries are pinned, not
47+
just described.
48+
49+
No new keys and no contract edit — the pair was declared, documented and
50+
consumed already; this is the production half catching up (ADR-0049
51+
enforce-or-remove, restoration direction).
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(engine): `deleteBehavior: 'set_null'` on a `multiple: true` reference field removes the deleted MEMBER from the stored array instead of nulling the whole slot, and the temporary 409 hold shipped with #9437 is removed (#9438)
6+
7+
On a set-valued foreign key, "set null" now means what it has to mean: the
8+
reference to the deleted record is filtered out of the stored array and the
9+
remaining members are written back untouched. Before the interim hold, this
10+
limb wrote `null` over the WHOLE array — a row holding `["acc_a","acc_b"]`
11+
re-read as `null` after `acc_a` was deleted, silently dropping the live
12+
reference to `acc_b`.
13+
14+
**The residual shape is the ruled one, consumed rather than decided here.**
15+
When the last member is removed, the field is written as **`[]`, never
16+
`null`** — the representation `FieldSchema` pins as verbatim contract
17+
(`packages/spec/src/data/field.zod.ts`, the `multiple` and `required` doc
18+
blocks; #9447, maintainer ruling 2026-08-18, binding for every writer). The
19+
open question that kept this limb held back was exactly that shape; it is
20+
now answered at the spec, and this write consumes the answer.
21+
22+
**The temporary holding position is removed in the same stroke — it was
23+
built to be removed.** #9437 shipped an explicit interim: any delete that
24+
would take the `set_null` limb on a multi-value reference was refused
25+
`DELETE_RESTRICTED` / 409, with a `developerMessage` naming the refusal
26+
TEMPORARY and citing the tracking issue literally. Those deletes now
27+
succeed and remove the member. The refusal envelope for a genuinely
28+
configured `restrict` is unchanged, and the interim's extra sentence is gone
29+
with the interim.
30+
31+
Removal compares whole members (`String(v) !== String(id)`), the same
32+
reading the dependents narrowing already applies — the probe's `$contains`
33+
pushdown is a substring superset, so an id that is a prefix of another
34+
neither loses its own member nor takes its neighbor's. Every other
35+
disposition is untouched: `cascade` still deletes dependents, a
36+
single-valued `set_null` still clears its foreign key to `null`, an
37+
explicit `restrict` still refuses with its own sentence, and the required-FK
38+
escalation stays exactly as it was.
39+
40+
Pinned against the driver double that models the JSON TEXT column and
41+
against a real `SqlDriver` on better-sqlite3 through the real data-plane
42+
delete: member removal with a surviving sibling that still resolves, the
43+
emptying case asserted literally as `[]` and not `null` on a re-read of the
44+
database, and the controls beside them.
45+
46+
Note: `required` on a multi-value lookup now *documents* non-empty-array
47+
semantics (same ruling), but the validator does not yet enforce it — that
48+
enforcement gap is tracked separately in #9476 and is not changed here.

‎.changeset/mighty-ducks-repeat.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
Published README documents the record-view audit surface that actually shipped
6+
7+
The README was corrected against the shipped surface before record-view auditing landed,
8+
so it still told readers that "reads and views are not on the ledger" and that the plugin
9+
takes no configuration. Both became false when the `read` action, its writer and the
10+
`record_views` list view merged. This is a docs-only change; it needs a version bump
11+
because the README is in the package's published `files` array, and a correction with no
12+
release never reaches the npm package page at all.
13+
14+
What the page now documents, each point verified against the source rather than against a
15+
description of it:
16+
17+
- the `read` action and its writer, in the action table and in the shipped list views;
18+
- the per-object opt-in as what it is — an **install-time list** passed to the plugin
19+
constructor (`new AuditPlugin({ readAudit: { objects: [...] } })`), explicitly **not** an
20+
`enable.auditReads` object-metadata key. A declarable key can be set on an object in a
21+
deployment that never installs the plugin, producing metadata that reads as audited and
22+
writes nothing, which is the exact class of claim this page was corrected to remove;
23+
- the three settings the plugin forwards, and the one writer knob (`maxBufferedEvents`) it
24+
does not, which is reachable only by calling `installReadAuditWriter` directly;
25+
- the record-detail discriminator that keeps list and search reads out of scope, including
26+
the `$or` / `$not` refusal and the AND-composed predicate the security middleware leaves
27+
behind;
28+
- batched writes off the request path, the view-instant `created_at`, and the two loud
29+
once-only failure postures (buffer overflow, failed ledger write);
30+
- the two declared boundaries — a system-elevated read and a read with no principal both
31+
write no row;
32+
- that no field values are recorded, and therefore that the ledger cannot answer what a
33+
viewer actually saw;
34+
- that the shipped `record_views` view carries an `ip_address` column which is always empty
35+
on a `read` row, because no read-path writer stamps it.
36+
37+
Record-view auditing adds no enterprise dependency: this package's declared edition is
38+
`open`, and the opt-in is ordinary plugin configuration. The two enterprise-dependent
39+
behaviours already annotated on the page — the hierarchy resolver and the archive
40+
datasource — are unchanged.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os migrate meta --from N` can finally open the retired-key sources it exists to rewrite (#9418)
6+
7+
The codemod refused its own input class. A retired authorable key is a
8+
`retiredKey()` tombstone — `z.never()` carrying the upgrade prescription — so the
9+
current schema does not strip it, it **rejects** it. And a real
10+
`objectstack.config.ts` runs that schema itself: `os init` scaffolds
11+
`export default defineStack({ … })`, larger projects spread `defineView` /
12+
`defineAgent` / `defineFlow` across per-artifact modules, and every one of those
13+
`define*` helpers is a `Schema.parse()`. The rejection therefore fired while the
14+
config module was being **evaluated**, inside the load, before `os migrate meta`
15+
reached its first conversion — the command exited 1 having rewritten nothing.
16+
17+
The message it printed was the instruction that sent the author there. The
18+
sentence "Run `os migrate meta --from <N>` to rewrite existing sources
19+
automatically." ships **144 times across 39 files** under `packages/spec/src`, so the v17 upgrade path closed
20+
a loop on itself: hit a retired key, get told to run the codemod, watch the
21+
codemod refuse **because of** the retired key.
22+
23+
**The fix is a tolerant load for that one command.** There was no CLI-side
24+
validation step to reorder — the gate lives in the loaded module — so
25+
`loadConfig()` gains an opt-in `authoredSource` mode that replaces each
26+
`@objectstack/spec` entrypoint the config imports (the root **and** the subpaths
27+
the example apps author through, `@objectstack/spec/ui`, `/ai`, `/data`, …) with
28+
a generated shim. The shim re-exports the real module and wraps its `define*`
29+
helpers as try-real-then-authored: the real helper runs first, and only when the
30+
current schema refuses the artifact is it handed on **exactly as authored**, with
31+
the swallowed verdict announced on stderr.
32+
33+
Three properties keep this a restoration rather than a widening of what the
34+
command accepts:
35+
36+
- **A source that loads today loads identically** — the real helper still runs,
37+
so its defaults and transforms still apply (`defineForm` still moves
38+
`schemaId` into `data`, `defineStack` still merges actions into objects). Only
39+
the sources that are refused today take the new path.
40+
- **Validation is moved after the conversion, not skipped.** The command still
41+
parses the **migrated** stack through `ObjectStackDefinitionSchema` and reports
42+
`schemaValid`, so a source broken for reasons the chain cannot fix is still
43+
reported as broken — after the codemod has done the part it can.
44+
- **Every other command still hears the tombstone.** `os build`, `os validate`
45+
and `os serve` keep the default strict load: the rejection is their upgrade
46+
channel, and only the codemod is entitled to read past it. Pinned both ways.
47+
48+
`os migrate meta --stored` was probed and is **not** affected: it never reads
49+
`objectstack.config.ts` at all — it boots from the compiled artifact and replays
50+
the chain over `sys_metadata` rows, and it already exits 0 in a project whose
51+
config carries a retired key. The defect was the authored-source arm alone.
52+
53+
The regression proof is shaped like a real project rather than like a test — the
54+
retired keys are authored through `defineStack` **and** through helpers imported
55+
from a spec subpath, which is where a tolerance scoped to `defineStack` alone
56+
would still have refused. The suite that shipped alongside the defect could not
57+
have caught it: its fixture is a bare `export default { … }` object literal, and
58+
a bare literal is validated by nobody at load.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
docs(plugin-audit): the published README stops documenting an `auditService` API, a row shape and an action vocabulary that do not exist (#9517)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Documentation only. No
8+
authorable property is added, renamed, retired or tombstoned; no schema, type or
9+
runtime behaviour changes. The only edited artifact is the package README, which
10+
ships in the package's `files` array. -->
11+
12+
`packages/plugins/plugin-audit/README.md` is in the package's published `files`
13+
array and `private` is unset, so it is **what the npm package page renders**. It
14+
documented an API surface with no implementation anywhere in the repo, under a
15+
banner claiming SOC 2 / HIPAA / GDPR readiness.
16+
17+
**Measured against `origin/main` before anything was rewritten**, and the drift
18+
was wider than the ledger of it:
19+
20+
- **Every `auditService.*` method the README called is absent from the repo** —
21+
`getFailedActions`, `logAdminAction`, `logDataAccess`, and also
22+
`getRecordHistory`, `getUserActivity`, `searchLogs`, `getRecordSnapshot`,
23+
`generateReport`, `archiveLogs`, `purgeLogs`, `logDataDeletion`,
24+
`logDataExport`. Twelve methods, zero implementations. A reader following the
25+
README wrote code that could not compile.
26+
- **`PluginAudit` does not exist**, and neither does the `.configure({...})`
27+
static it was called through — no class in this repo exposes one. The export is
28+
`AuditPlugin`, a `Plugin` class registered as `kernel.use(new AuditPlugin())`
29+
and taking **no configuration at all**. The documented config object
30+
(`trackObjects`, `trackFields`, `retentionDays`, `autoArchive`, `excludeUsers`,
31+
`trackSystemEvents`) was fabricated in full.
32+
- **`IAuditService` is not in `@objectstack/spec/contracts`** — the README's
33+
"Contract Implementation" section named an interface the spec has never
34+
declared.
35+
- **The row shape was not the shipped one.** The README declared `timestamp`,
36+
`userName`, `userEmail`, `recordName`, `changes`, `sessionId`, `status` and
37+
`errorMessage`. `sys_audit_log` declares none of them.
38+
- **The action values were outside the enum.** `'insert'`, `'auth:login'`,
39+
`'security:password_reset'`, `'workflow:approval'` and `'user_role_change'` are
40+
not forms this object accepts; the namespaced-colon spelling never was one.
41+
- **The object name was wrong** — `audit_log`, not `sys_audit_log`.
42+
- **The REST namespace does not exist.** Six `/api/v1/audit/*` routes were
43+
documented; the object declares `apiMethods: ['get', 'list']` and is read over
44+
the ordinary object API.
45+
46+
The compliance paragraph is **deleted, not softened or relocated**: a
47+
regulatory-readiness claim is a company-level statement needing an accountable
48+
owner, and it does not belong in a package README. The three external
49+
SOC 2 / GDPR / HIPAA links that existed only to support that framing are gone
50+
with it.
51+
52+
The replacement documents only what the code can be pointed at: the real exports;
53+
the real `sys_audit_log` columns; the seven-value action enum **with the writer
54+
for each value**, so a reader can check any row of it; the credential masking on
55+
`old_value` / `new_value`; and the coverage model, which is
56+
**all objects minus an exclusion list** rather than the fabricated per-object
57+
`trackObjects` config — subtraction, because the object universe is open and an
58+
enumerated allow list would silently stop auditing everything registered after
59+
boot.
60+
61+
Three things are now stated that the old README obscured, all of them gaps a
62+
reader could otherwise mistake for coverage:
63+
64+
- **reads and views are not on the ledger** — no writer emits a read action;
65+
- **failed operations are not on the ledger** — there is no success/failure
66+
column, and the writers fire only on `after*` events, i.e. only on operations
67+
that succeeded, so `getFailedActions`-style "security monitoring" had no
68+
mechanism behind it in the first place;
69+
- **`ip_address` / `user_agent` are populated on auth events only** — the
70+
record-level writer does not stamp them, so a null client fingerprint on a CRUD
71+
row does not mean the request had none.
72+
73+
Two dependency boundaries are **named with their degraded behaviour** rather than
74+
left silent, following the `access-recipes.mdx` pattern: hierarchy-relative
75+
permission scopes need `@objectstack/security-enterprise` and **fail closed to
76+
`own`** without it, so a grant written to let managers read their reports' audit
77+
rows shows them only their own on an open build; and `lifecycle.archive` needs a
78+
registered `archive` datasource, **failing closed to retention** without one —
79+
nothing is ever deleted and the table grows, which is the safe direction for a
80+
ledger but not the documented one.

0 commit comments

Comments
 (0)