Skip to content

Commit 6470762

Browse files
committed
Merge origin/main into #4583 batch A
Conflict was in `content/docs/references/data/datasource.mdx` — a GENERATED file, conflicting because both sides regenerated it: main's #4595 taught the generator to spell import examples from the real export surface, while this branch removed `DatasourceCapabilities` from that surface. Resolved by regenerating rather than hand-merging. The result is exactly what the two changes imply together — #4595's corrected spelling minus the removed export: import { DatasourceSchema, DriverDefinitionSchema, DriverType, ExternalDatasourceSettingsSchema } from '@objectstack/spec/data'; #4595 also added an import-surface baseline, which listed `data/DatasourceCapabilities — no type export` as a known gap. This removal closes that gap, and the baseline is shrink-only, so the stale line is deleted (--update-import-baseline) — a stale exemption would otherwise stay available to excuse the NEXT missing export. The three auto-merged baseline JSONs (authorable-surface, api-surface, json-schema.manifest) were not trusted as merged text: `gen:schema` rewrites them wholesale once its vanished-key gate passes, and the full build confirms no residue of either side's removals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu
2 parents 6db3ccc + 45a5787 commit 6470762

295 files changed

Lines changed: 4196 additions & 762 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/plugin-webhooks': patch
4+
---
5+
6+
ADR-0078 Phase 4, decided rather than deferred: the silent skips stop being silent at runtime. The registry — the one choke point every metadata door goes through — now emits a functional-completeness diagnostic at registration, and the webhook enqueuer's zero-trigger skip warns instead of returning `null` wordlessly.
7+
8+
**The Phase 4 ruling.** The phase had two halves, and they got opposite verdicts:
9+
10+
- **Generative rule sweep: rejected — not deferred.** A generator can enumerate candidates ("which optional keys might be load-bearing?") but cannot verify runtime skip sites, and a rule without its skip-site citation is a false prescription — this campaign shipped four of those and every one was caught by the verification pass a generator would skip. The route is structurally wrong; no amount of waiting produces the evidence that would fix it.
11+
- **Registration-time diagnostics: built now.** The evidence was already in hand, not pending: #3896 (Setup authoring inserted `sys_sharing_rule` rows directly, bypassing the schema that "required" `criteria`) and cloud's `rowColor.mapping` (an `as never` cast bypassed tsc) prove that doors which skip Zod and lint are real. The author-time gate only protects metadata that passes through `os build` / `validate` / `lint`; `SchemaRegistry.registerObject` is where *every* door converges — declared stacks, plugin objects, `extend` contributions, `saveMetaItem`, raw `registerObject` calls.
12+
13+
**Same predicate, same rule ids, different posture.** The registry calls the same `checkFieldCompleteness` that `validate-functional-completeness` uses, so the boot log carries the *same rule ids* the lint reports (`field/summary-without-operations`, …) — an operator or an AI reading the log greps the id straight into the same docs and suppression story. But the registry **warns and never throws**: ADR-0078 §1's error severity means *the instance is dead*, not *the system is dead* — an inert field must not kill a boot that thousands of healthy objects share. Errors block at author time; the registry's job is to make sure the silence never survives to runtime unobserved.
14+
15+
One line per object with every finding aggregated (not per request — the hot path stays free; not per finding — a three-dead-field object is one greppable line). Follows `warnStrippedLegacyApiMethods` (#3543) exactly: module-level once-per-object dedup, injectable `warn`, pure observation that never mutates the schema.
16+
17+
**The webhook skip now names itself.** `auto-enqueuer.ts`'s `if (triggers.size === 0) return null` sat under a comment blessing the empty case as "a manual-only webhook" — a mode #3196 removed (no manual fire path exists). The skip now warns with the author-time rule id (`webhook/without-triggers`), and the comment tells the truth. Only *active* rows reach the parse (`where: { active: true }` — verified, not assumed), so a deliberately disabled webhook stays warning-free.
18+
19+
**Scope honesty:** field rules and the webhook rule get the runtime twin. `view/layout-without-binding` stays author-time-only — views don't register through this choke point and the renderer half of the evidence lives in objectui.
20+
21+
Tracked in #4544. This closes the ADR-0078 loop end to end: author-time error, runtime warning, one shared predicate deciding both.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
---
3+
4+
Tooling-only: expand `scripts/adr-anchors.json` from 8 to 15 anchors — the bounded authz/security ADR sweep promised in #4575. Releases nothing.
5+
6+
The first 8 anchors covered only #3723's blast radius. This sweep audited the decisions in ADR-0057 / 0066 / 0068 / 0090 / 0091 / 0095 / 0105 for other load-bearing realizations — places where a reasonable engineer could "fix" the code and be reverting a decision. Seven files added, each with its reversal story:
7+
8+
- `posture-ladder.ts` (0095) — posture derives from capability grants; re-reading the better-auth role reopens the #2836 dual-track class.
9+
- `grant-validity.ts` (0091) — window enforcement lives at resolution time; "optimize it into a cleanup job" is the banned move (ADR-0049).
10+
- `tenant-layer.ts` (0095 D1) — Layer 0 shares no compiler/merge/bypass with business RLS; "deduplicate into the RLS compiler" would let a Layer-1 change weaken tenant isolation.
11+
- `auto-org-admin-grant.ts` (0105) — wall-less postures get `organization_admin_no_bypass`; "why two sets?" collapses into an environment-wide superuser (the F2 finding).
12+
- `invitation-placement.ts` (0105 D8) — issuance dry-runs the gate; "acceptance re-checks anyway" is false (acceptance runs under system context) and skipping it is an escalation hole.
13+
- `position.zod.ts` (0090 D3) — positions are flat; adding `parent` is the exact mistake ADR-0057 D5 retired.
14+
- `permission-evaluator.ts` (0066 D2 + 0057 D1) — superuser bypass derives from the wildcard grant, no stored boolean and no role fast-path.
15+
16+
Plus one extension: the existing `resolve-authz-context.ts` anchor gains ADR-0068 (`platform_admin` is derived from an unscoped grant — no trusted stored boolean, the classic "add an `is_admin` column" reversal target).
17+
18+
All 15 pass as-is — every anchored file already cited its governing ADRs — so this changes zero code, only registers what must not be silently un-cited. Negative-tested by stripping `ADR-0091` from `grant-validity.ts` (fails, printing the invariant).

.changeset/adr-anchors-guard.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
---
3+
4+
Tooling-only: `pnpm check:adr-anchors` — code an accepted ADR governs must keep naming it (#3723 follow-up). Adds `scripts/check-adr-anchors.mjs` + `scripts/adr-anchors.json` (8 seeded anchors, all in the blast radius of the incident), a `Lint & Type Check` step, and Prime Directive #13 in `AGENTS.md` ("an accepted ADR binds until a superseding ADR says otherwise"). Releases nothing — no package changes.
5+
6+
The incident this closes: three accepted ADRs said `sys_member.role` must never carry RBAC authority, and a patch-level changeset made app-declared names storable there anyway; a follow-up then made it automatic in every host. The mechanism was not carelessness — the file being edited never named the ADRs that governed it, so the author could not have known. The check is a presence check (does the governed file still reference its ADR ids?), deliberately dumb; the value is that the failure carries the **invariant**, not just an id to paste back, and it fires on exactly the diff that warrants a second look — someone rewriting a governed block and dropping the rationale with it.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A blueprint `formula` field can finally say what it computes: `BlueprintFieldSchema` and its OpenAI-strict mirror both gain `expression`.
6+
7+
`BlueprintFieldSchema.type` is the **full** `FieldType` enum, so the AI-build design step could always NAME a `formula` field — but neither the lenient schema nor the strict mirror the model generates against had any key for the body. There was no way, anywhere on that surface, to state what the formula computed. It materialized bare, and cloud's graph-lint then correctly reported `formula_without_expression` with the fix *"Set field expression to a CEL formula"* — a fix the agent could not write in the blueprint it was holding. Detected, but unfixable on the surface that produced it.
8+
9+
This is the exact hole `summaryOperations` closed for roll-ups in cloud#970 (see this file's own test: *"z.object STRIPS unknown keys, so before this slot existed a blueprint that correctly declared `{ type:'summary', summaryOperations:{…} }` lost the config at the parse waist and materialized runtime-dead"*). `formula` was simply left behind — the same defect, one field type over.
10+
11+
It bites hardest through `nameField`, whose own guidance tells the model to point at a formula for numbered entities (invoice/ticket) that compose `number · name`. Without an expression slot, following that advice produces a record title that is blank on every card, lookup chip and breadcrumb.
12+
13+
**The pin matters more than the key.** A1's root cause is not a forgotten property — it is that two schemas describe the same shape and nothing forced them to agree. The mirror is what the model may EMIT; the lenient schema is what downstream READS. Drift in either direction silently drops authored config. A new test asserts the two field schemas carry **exactly** the same keys, so the next key added to one cannot go missing from the other.
14+
15+
Cloud's `objectBody` carries the value through to materialization (companion change in the `cloud` repo); it reads the key via cast, as it already does for `defaultValue`, so it is inert against an older spec and live as soon as this ships.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/objectql": patch
4+
"@objectstack/driver-sql": patch
5+
---
6+
7+
fix(driver-sql,spec,objectql): a `defaultValue` runtime token never becomes a column DEFAULT (#4560)
8+
9+
`Field.user({ defaultValue: 'current_user' })` is resolved by the **engine**, at
10+
insert time, from the request's `ExecutionContext` — and with no authenticated
11+
user (system / anonymous writes: seed replay, package install, boot
12+
provisioning) `applyFieldDefaults` deliberately leaves the field **unset**
13+
rather than stamp a bogus owner.
14+
15+
The SQL DDL had never heard of the token. `createColumn` passed any non-object
16+
`defaultValue` straight through to `col.defaultTo(dv)`, so the column was
17+
created as `DEFAULT 'current_user'` and the **database** overrode the engine's
18+
decision: every insert that omitted the field stored the literal string
19+
`current_user` in a `lookup('sys_user')` column — a value that is not any user's
20+
id. `?expand` resolves it to nothing, and on an owner / approver field it is a
21+
silent mis-attribution. Found by #4551's dangling-reference audit on its first
22+
run against a real boot; #4441's referential check could never have caught it,
23+
because it inspects the values a **caller** supplied and here nobody supplied
24+
one.
25+
26+
**The token vocabulary is now declared once, in `@objectstack/spec/data`**
27+
(`DEFAULT_VALUE_TOKENS`, `isRuntimeDefaultToken`, `isNowDefaultToken`,
28+
`isCurrentUserDefaultToken`, `isAppResolvedDefaultToken`). The engine's
29+
insert-time resolution and the driver's DDL read the same set, which is the
30+
actual defect: `'NOW()'` was special-cased in the branch immediately above for
31+
precisely this reason, and `current_user` — the same convention family — simply
32+
had no entry anywhere the DDL could see. A token added to the set tomorrow is
33+
excluded from literal column DEFAULTs automatically, rather than leaking its own
34+
spelling into the database the way this one did.
35+
36+
**DDL, in one place** (`applyDeclaredColumnDefault`, shared by column creation
37+
and the SQLite table rebuild):
38+
39+
- `'NOW()'` → the driver-native canonical default, exactly as before;
40+
- any other runtime token → **no column default at all** (the engine owns it);
41+
- Expression envelopes (`{ dialect, source }`) → unchanged, no default;
42+
- a real literal → emitted verbatim, unchanged.
43+
44+
**Existing databases carry the wrong DEFAULT**, so it is corrected through the
45+
managed schema-drift path (#2186) rather than a bespoke migration: a new
46+
`default_mismatch` finding with a `drop_column_default` op, categorised `safe`
47+
(the statement cannot fail and touches no rows). Dev boots with
48+
`autoMigrate: 'safe'` reconcile it automatically; everywhere else it is reported
49+
with an actionable hint and applied by `os migrate apply`. Postgres/MySQL use
50+
`ALTER COLUMN … DROP DEFAULT`; SQLite, which cannot alter a default in place,
51+
goes through the existing table rebuild — which now re-materialises every
52+
column's default from **metadata**, so a sibling `defaultValue: 'NOW()'` column
53+
keeps the default it always had instead of losing it to the rebuild.
54+
55+
**Rows already holding the bogus value are NOT rewritten.** That is #4551's
56+
standing rule — report, never rewrite — so they stay visible to the
57+
dangling-reference audit for operators to resolve deliberately.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/observability": patch
4+
---
5+
6+
fix(runtime): declarative `defineJob` cron jobs are actually scheduled (#4567)
7+
8+
Every background job authored as `defineJob({ schedule: { type: 'cron', … } })`
9+
was **silently never scheduled**. `JobSchema.parse` rewrites the cron
10+
`expression` into the canonical expression envelope
11+
(`{ dialect: 'cron', source: '0 1 * * *' }` — the authoring/persistence tier),
12+
but `AppPlugin` handed `job.schedule` verbatim to `IJobService.schedule`, whose
13+
boundary contract documents `expression` as a **bare cron string** because
14+
`CronJobAdapter` passes it straight to croner. croner rejected the object
15+
(`CronPattern: Pattern has to be of type string.`), the throw was swallowed by a
16+
per-job `try/catch` that only `warn`ed, and the author saw a green build and a
17+
green boot with the job never running. `interval` / `once` schedules and
18+
flow `schedule` triggers were unaffected.
19+
20+
**Fix (contract-first).** The authoring→boundary downgrade now happens at the one
21+
place the two tiers meet — `AppPlugin`'s declarative-job registration, alongside
22+
the existing `retryPolicy` / `timeout` threading — via
23+
`toBoundaryJobSchedule()`. The adapters stay strict: no `typeof === 'object'`
24+
tolerance was added downstream, so the boundary keeps exactly one shape.
25+
A schedule that cannot be reduced to it (unknown type, AST-only or non-`cron`
26+
expression envelope, missing `intervalMs` / `at`) is rejected by name.
27+
28+
**The failure path is no longer silent.** A job that cannot be scheduled now logs
29+
at **error** level with its own message (`Background job FAILED TO SCHEDULE — it
30+
will never run`), plus a boot summary line when any job failed, and increments
31+
the new `job_schedule_failures_total` counter
32+
(`SEMCONV.jobScheduleFailuresTotal`, labels `app` / `job`) on the observability
33+
metrics registry. "Failed to schedule" no longer shares the quiet `warn` used by
34+
"handler not found in bundle.functions" — the first is an outage of declared
35+
work, the second is a job that was never going to run.
36+
37+
No authoring change is required: existing `defineJob` cron declarations start
38+
working on upgrade.

.changeset/duplicate-fix-guard.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
---
3+
4+
ci: fail a PR at open time when an earlier open PR already declares a fix for
5+
the same issue (#4588)
6+
7+
Release-nothing: adds `.github/workflows/duplicate-fix-guard.yml` and updates
8+
agent process docs (AGENTS.md, CLAUDE.md, pm-dispatch claim template) — no
9+
package code.
10+
11+
GitHub lets any number of open PRs declare `Fixes #N` for the same issue.
12+
On 2026-08-02, #4555 and #4559 both declared `Fixes #4551` and both were
13+
implemented in full — 834 duplicate lines through the whole gate suite — with
14+
the duplication machine-detectable from the second PR's open (03:08) yet
15+
unnoticed by any human until 08:52. The shared GitHub identity made the
16+
issue's assignee useless as a warning: "assigned to os-zhuang" reads the same
17+
whether the claimant is you or another session.
18+
19+
Three changes, one per hole:
20+
21+
- **Duplicate Fix Guard workflow**: on PR opened/edited/reopened/synchronize,
22+
parse same-repo closing keywords and fail the PR if an EARLIER open PR
23+
(lower number) declares the same issue, naming it. First come, first
24+
served — matching the pm-dispatch "first claim comment wins" convention.
25+
The check is body-driven and re-runs on `edited`, so a red PR goes green
26+
the moment the conflict is resolved either way.
27+
- **Claim comments must carry a session ID** (pm-dispatch template, AGENTS.md,
28+
CLAUDE.md): under a shared identity, the comment's session line is the only
29+
thing that makes "is this claim mine?" answerable.
30+
- **Branch naming `claude/issue-<n>-<slug>`** (AGENTS.md): puts the issue
31+
number where `git ls-remote | grep issue-<n>` can find it; the workflow
32+
warns (never fails) when a fix PR's branch names no declared issue.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Reference docs: import examples are now spelled from the package's real export surface
6+
7+
`build-docs.ts` derived each page's "TypeScript Usage" block from the JSON Schema file
8+
name — the value import verbatim, the `import type` line with a `Schema` suffix stripped —
9+
and nothing verified either name existed. `check:docs` could not catch it: it diffs the
10+
generator's output against the committed docs, so a name the generator invents stays "in
11+
sync" with itself forever. 150 of the committed `import type` names did not compile, and
12+
the `.parse()` example called a type rather than the schema const.
13+
14+
Both lines are now resolved against `api-surface.json`, the committed record of every
15+
`name (kind)` per entry point: only names the entry really exports are emitted, and the
16+
example parses with the actual schema const. A name that resolves to nothing is dropped
17+
from the page and recorded in the new `docs-import-surface.baseline.json` — a shrink-only
18+
ratchet, so removing a type alias while its schema keeps a reference page now turns
19+
`check:docs` red instead of silently publishing a dead import.

0 commit comments

Comments
 (0)