diff --git a/.changeset/showcase-inert-wirings.md b/.changeset/showcase-inert-wirings.md new file mode 100644 index 0000000000..552cf11373 --- /dev/null +++ b/.changeset/showcase-inert-wirings.md @@ -0,0 +1,42 @@ +--- +--- + +Showcase-only: fix four declarations the reference app made that the runtime +never honoured, each announced by one line in the boot warning block. Releases +nothing — `@objectstack/example-showcase` is private. + +- **The nightly job now exists.** `showcase_health_sweep` named + `handler: 'sweepProjectHealth'` and no function of that name was defined + anywhere, so `AppPlugin` skipped it at every boot and "Nightly Project Health + Sweep" had never run. Implemented for real: it recomputes + `showcase_project.health` from budget burn measured against delivered task + progress, over an engine handle captured at `onEnable` (a job handler is + invoked with `{ jobId, data }` and no data engine). It is registered in the + bare-callable form rather than the `effect: 'writes'` declaration it wants: + that declared form cannot survive `objectstack build` today, filed as #4976. +- **`showcase.export_data` now materializes.** The capability declared no + owning package, so it was never written to `sys_capability` — leaving + `OpsPermissionSet` granting a permission that would never exist. It now + authors the ADR-0086 D3 `packageId` provenance. The platform half (an + app-declared capability can never receive the registry stamp, and a refused + declaration still suppresses the back-compat derivation) is filed as #4967. +- **Retry policies use the canonical key.** Two `try_catch` nodes still spelled + the base delay `retryDelayMs`, which only kept working through the + `retry-policy-converged` conversion — and that conversion retires in protocol + 18. Renamed to `backoffMs`. `maxRetryDelayMs` is unchanged: it is a canonical + key of `RetryPolicySchema`, not part of that rename. +- **Seed data no longer breaks ADR-0104 on its own first boot.** Ten + `showcase_task.cover` values were inline `data:image/svg+xml` URIs in a + `Field.image()`, whose stored form is an opaque `sys_file` id. Because a boot + may not attest a contract it has already broken (#4769), a brand-new + datastore could never auto-attest `adr-0104-file-references` — the gate stayed + open on day one of every fresh install. The values are removed; `cover` stays + declared, with its gallery binding, and is populated by uploading a cover. + +Guarded by `test/inert-wirings.test.ts`: every declared job's handler must +resolve against `defineStack({ functions })` in a form the build can carry, +every declared capability must +resolve an owning package, no permission set may grant an undeclared +capability, no **source file** may author `retryDelayMs` (the parsed stack +cannot answer this — the conversion has already rewritten it), and no seeded +file-class value may fail its ADR-0104 stored shape. diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index bad1354a4e..c2a0e600d5 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -30,7 +30,7 @@ import { CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspac import { allFlows } from './src/automation/flows/index.js'; import { allWebhooks } from './src/automation/webhooks/index.js'; import { allHooks } from './src/data/hooks/index.js'; -import { allJobs } from './src/automation/jobs/index.js'; +import { allJobs, sweepProjectHealth, bindShowcaseJobRuntime } from './src/automation/jobs/index.js'; import { allEmails } from './src/system/emails/index.js'; import { allBooks } from './src/system/books/index.js'; import { allApis } from './src/system/apis/index.js'; @@ -209,9 +209,28 @@ export default defineStack({ // A flow function is PURE: it takes `inputs`, RETURNS a value, and a later // declarative node uses or persists it — it does no data I/O of its own // (#4396), which is why it needs no `effect` declaration here. + // + // A JOB handler resolves through this same map (`collectBundleFunctions`), so + // `sweepProjectHealth` — the handler `HealthSweepJob` names — lives here too. + // It is the case the pure contract does not cover: a nightly sweep has no + // downstream declarative node to persist for it, so it writes over an engine + // handle captured at `onEnable`. + // + // ⚠️ Do NOT rewrite this as `{ handler: sweepProjectHealth, effect: 'writes' }`. + // That declared form (#4396) is the honest spelling for a writer and is what + // this entry wants — but it cannot survive `objectstack build` today: the CLI + // lowers it to `{ handler: 'sweepProjectHealth', effect: 'writes' }` and + // `FlowFunctionEntrySchema` accepts a bare callable, a declaration whose + // `handler` is a CALLABLE, or a bare string ref — never a declaration whose + // handler has been lowered to a string. `pnpm build` fails with + // `functions: invalid_union`. Filed as #4976; switch back once it lands. + // Nothing is lost at runtime meanwhile: `effect` has exactly one consumer, + // the `script` node's `unmeasuredEffect` metric, and the JOB path drops it + // (`collectBundleFunctions` keeps only the handler). functions: { summarizeCompletedTask: ({ input }: { input: Record }) => `Completed: ${String(input.title ?? 'task')} (priority ${String(input.priority ?? 'normal')}).`, + sweepProjectHealth, }, jobs: allJobs, emailTemplates: allEmails, @@ -262,4 +281,9 @@ export const onEnable = async (ctx: unknown): Promise => { // real pending requests land in the inbox (cannot be a seed — see // seed-approval-demo.ts). registerShowcaseApprovalDemo(ctx as Parameters[0]); + // Hand the nightly health-sweep job its data handle. A job handler is invoked + // by the job service with `{ jobId, data }` and no engine (flow functions are + // pure by default, #4396), so `onEnable` — the one place the app is handed a + // live engine — is where the sweep gets one. + bindShowcaseJobRuntime(ctx as Parameters[0]); }; diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 3c467ca620..86266a7b1a 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -981,7 +981,14 @@ export const ResilientSyncFlow = defineFlow({ type: 'try_catch', label: 'Push with retry', config: { - retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2, maxRetryDelayMs: 10000 }, + // Canonical retry policy (`@objectstack/spec` 17.0.0, #4661): the base + // delay is `backoffMs` on BOTH `try_catch.retry` and `job.retryPolicy`. + // The pre-17 automation-side spelling `retryDelayMs` is tombstoned and + // only survives via the `retry-policy-converged` conversion, which + // retires in protocol 18 — never author it. `maxRetryDelayMs` is NOT + // part of that rename: it is a canonical key of `RetryPolicySchema` + // (the ceiling for a single backoff delay). + retry: { maxRetries: 3, backoffMs: 1000, backoffMultiplier: 2, maxRetryDelayMs: 10000 }, errorVariable: '$error', try: { nodes: [ @@ -1173,7 +1180,8 @@ export const ProjectEscalationFlow = defineFlow({ type: 'try_catch', label: 'Push to incident system', config: { - retry: { maxRetries: 2, retryDelayMs: 500, backoffMultiplier: 2 }, + // Canonical `backoffMs` — see the note on ResilientSyncFlow above. + retry: { maxRetries: 2, backoffMs: 500, backoffMultiplier: 2 }, errorVariable: '$error', try: { nodes: [{ id: 'push', type: 'http', label: 'POST incident', config: { url: 'https://api.example.com/v1/incidents', method: 'POST', body: { project: '{record.id}', severity: 'critical' } } }], diff --git a/examples/app-showcase/src/automation/jobs/index.ts b/examples/app-showcase/src/automation/jobs/index.ts index 7252b4a085..5648191d98 100644 --- a/examples/app-showcase/src/automation/jobs/index.ts +++ b/examples/app-showcase/src/automation/jobs/index.ts @@ -2,7 +2,17 @@ import { defineJob } from '@objectstack/spec'; -/** Nightly job — recompute project health. Handler is registered in defineStack({ functions }). */ +export { sweepProjectHealth, bindShowcaseJobRuntime, healthFor } from './sweep-project-health.js'; + +/** + * Nightly job — recompute project health. + * + * `handler` names a key of `defineStack({ functions })` (the only form + * `JobSchema.handler` accepts); `sweepProjectHealth` is registered there with + * `effect: 'writes'`. It was declared here for a long time with no function of + * that name anywhere in the example, so the AppPlugin skipped it at every boot + * and the sweep never ran (#4774 / #4888) — see `./sweep-project-health.ts`. + */ export const HealthSweepJob = defineJob({ name: 'showcase_health_sweep', label: 'Nightly Project Health Sweep', diff --git a/examples/app-showcase/src/automation/jobs/sweep-project-health.ts b/examples/app-showcase/src/automation/jobs/sweep-project-health.ts new file mode 100644 index 0000000000..73c11a82b8 --- /dev/null +++ b/examples/app-showcase/src/automation/jobs/sweep-project-health.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sweepProjectHealth` — the handler behind the nightly `showcase_health_sweep` + * job (see `./index.ts`). + * + * ## Why this file exists + * + * The job declared `handler: 'sweepProjectHealth'` and nothing of that name was + * ever defined, so every boot printed + * + * ``` + * WARN [AppPlugin] job handler not found in bundle.functions — skipping + * {"appId":"com.example.showcase","job":"showcase_health_sweep","handler":"sweepProjectHealth"} + * ``` + * + * and the "Nightly Project Health Sweep" never ran (#4774 / #4888). A scheduled + * job is one of the capabilities the showcase exists to demonstrate end to end, + * so the fix is a real implementation rather than deleting the declaration — + * "never advertise a capability the runtime doesn't deliver" (AGENTS.md Prime + * Directive #10) cuts both ways. + * + * ## Why the engine handle is captured rather than passed in + * + * A job handler is resolved through the SAME `defineStack({ functions })` + * registry as a `script` flow node (`collectBundleFunctions` in + * `@objectstack/runtime`), and the job service invokes it with + * `{ jobId, data }` — `IJobService`'s `JobHandler` context — plus the `bundle` + * the AppPlugin adds. There is deliberately no data engine in that context: a + * flow function is PURE by default, returning a value a later declarative node + * persists (#4343 / #4396). + * + * A background job is the case that contract does not cover — nothing + * downstream is going to persist for it — so it does its own I/O over a handle + * captured at `onEnable`, and DECLARES that in the `functions` map with + * `effect: 'writes'` (#4396). That declaration grants nothing; it tells the + * platform this callable's writes are not counted by the caller, so a run + * reports "cannot say" instead of silently claiming it wrote nothing. + * + * ## What it computes + * + * Health is budget burn measured against delivered progress — the drift between + * the money spent and the work finished: + * + * burn = spent / budget (0 when no budget is set) + * done = mean(task.progress) / 100 (0 when the project has no tasks) + * drift = burn - done + * + * red — over budget (burn > 1), or drift >= 0.30 + * yellow — drift >= 0.15 + * green — otherwise + * + * Only `active` / `on_hold` projects are swept: `planned` has nothing to burn + * yet, and `completed` / `cancelled` are settled facts a nightly job must not + * relitigate. Writes are limited to the projects whose health actually changed, + * so a steady-state sweep performs zero updates. + */ + +/** Statuses whose health is still in play. */ +const SWEPT_STATUSES = ['active', 'on_hold'] as const; + +/** Drift at or above which a project turns red (spending far ahead of delivery). */ +const RED_DRIFT = 0.3; +/** Drift at or above which a project turns yellow. */ +const YELLOW_DRIFT = 0.15; + +/** Bound on rows read per sweep — a demo dataset, read in one pass. */ +const READ_LIMIT = 1000; + +const SYS = { isSystem: true } as const; + +type Health = 'green' | 'yellow' | 'red'; + +interface JobHostEngine { + find: (object: string, query: unknown, options?: unknown) => Promise; + update: (object: string, data: Record, options?: unknown) => Promise; +} + +interface JobHostContext { + ql: JobHostEngine; + logger?: { + info?: (...a: unknown[]) => void; + warn?: (...a: unknown[]) => void; + }; +} + +/** + * The engine handle the job runs over, captured from the host context at + * `onEnable`. Module scope is what makes it reachable from a `functions` entry, + * which the job service calls with no context of its own — the "closed over a + * client at module scope" shape `effect: 'writes'` exists to declare. + */ +let host: JobHostContext | undefined; + +/** + * Give `sweepProjectHealth` its data handle. Called from `onEnable` in + * `objectstack.config.ts`, which is the one place the app is handed a live + * engine. Idempotent — a re-enable simply rebinds. + */ +export function bindShowcaseJobRuntime(ctx: JobHostContext): void { + host = ctx; +} + +/** Normalize the engine's list shape (array, or `{ records }`). */ +function rowsOf(result: unknown): Array> { + if (Array.isArray(result)) return result as Array>; + const records = (result as { records?: unknown })?.records; + return Array.isArray(records) ? (records as Array>) : []; +} + +/** Read a numeric column defensively — a currency/progress column may arrive as a string. */ +function num(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +/** + * The health verdict for one project — exported so the rule is unit-testable + * without an engine (see `test/job-health-sweep.test.ts`). + */ +export function healthFor(input: { + budget?: unknown; + spent?: unknown; + taskProgress: readonly number[]; +}): Health { + const budget = num(input.budget) ?? 0; + const spent = num(input.spent) ?? 0; + const burn = budget > 0 ? spent / budget : 0; + if (burn > 1) return 'red'; + + const done = + input.taskProgress.length > 0 + ? input.taskProgress.reduce((sum, p) => sum + p, 0) / input.taskProgress.length / 100 + : 0; + + const drift = burn - done; + if (drift >= RED_DRIFT) return 'red'; + if (drift >= YELLOW_DRIFT) return 'yellow'; + return 'green'; +} + +/** + * Recompute `showcase_project.health` for every in-play project. + * + * Registered as `functions.sweepProjectHealth` with `effect: 'writes'` and + * scheduled by `HealthSweepJob` (`0 1 * * *` UTC). + */ +export async function sweepProjectHealth(ctx?: { jobId?: string }): Promise { + const jobId = ctx?.jobId ?? 'showcase_health_sweep'; + if (!host) { + // Reached only if the job somehow fires before `onEnable` bound the + // handle. Functional degradation, not a durability one: nothing claimed to + // be persisted has been lost, and the next scheduled run recomputes + // everything from scratch (AGENTS.md "Degradation log levels"). + // eslint-disable-next-line no-console + console.warn(`[showcase] ${jobId}: no engine handle bound yet — skipping this run`); + return; + } + const { ql, logger } = host; + + const projects = rowsOf( + await ql.find('showcase_project', { + where: { status: { $in: [...SWEPT_STATUSES] } }, + fields: ['id', 'status', 'health', 'budget', 'spent'], + limit: READ_LIMIT, + context: SYS, + }), + ); + if (projects.length === 0) { + logger?.info?.('[showcase] project health sweep: no in-play projects', { job: jobId }); + return; + } + + const projectIds = projects.map((p) => String(p.id)); + const tasks = rowsOf( + await ql.find('showcase_task', { + where: { project: { $in: projectIds } }, + fields: ['project', 'progress'], + limit: READ_LIMIT, + context: SYS, + }), + ); + + const progressByProject = new Map(); + for (const task of tasks) { + const projectId = task.project == null ? '' : String(task.project); + if (!projectId) continue; + const progress = num(task.progress) ?? 0; + const bucket = progressByProject.get(projectId); + if (bucket) bucket.push(progress); + else progressByProject.set(projectId, [progress]); + } + + let updated = 0; + for (const project of projects) { + const id = String(project.id); + const next = healthFor({ + budget: project.budget, + spent: project.spent, + taskProgress: progressByProject.get(id) ?? [], + }); + if (next === project.health) continue; + try { + await ql.update('showcase_project', { id, health: next }, { context: SYS }); + updated += 1; + } catch (err) { + logger?.warn?.('[showcase] project health update failed', { + job: jobId, + project: id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + logger?.info?.('[showcase] project health sweep complete', { + job: jobId, + scanned: projects.length, + updated, + }); +} diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 81e6071574..7c455b858d 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -97,7 +97,12 @@ export const KIND_COVERAGE: Record = { // ── automation ── flow: { status: 'demonstrated', files: ['src/automation/flows/index.ts'] }, - job: { status: 'demonstrated', files: ['src/automation/jobs/index.ts'] }, + // `job` is only demonstrated end-to-end if its handler actually resolves — + // the declaration alone left the sweep registered and never run (#4774). + job: { + status: 'demonstrated', + files: ['src/automation/jobs/index.ts', 'src/automation/jobs/sweep-project-health.ts'], + }, // ── system ── datasource: { diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 663b07fc36..91fdca7a5c 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -23,15 +23,40 @@ import { Announcement } from '../objects/announcement.object.js'; */ /** - * Local, offline-safe placeholder cover image. Task `cover` seeds used to - * point at picsum.photos, which renders as a wall of broken images in - * offline/restricted-network environments (Gallery, All Views). A data: URI - * needs no network at all and still gives each card a distinct color + number. + * ## Why `showcase_task.cover` is declared but NOT seeded (#4891, ADR-0104) + * + * `cover` is `Field.image()`, and since ADR-0104 D3 wave 2 the STORED value of + * every file-class field (`file`/`image`/`avatar`/`video`/`audio`) is an opaque + * `sys_file` id — a managed file, resolved to a URL by `/files/:fileId` on + * read. It is deliberately id-shaped rather than "any non-empty string", so the + * two legacy forms are both rejected: an inline metadata blob (that is now the + * EXPANDED read form, derived not stored) and an external/`data:` URL (ADR-0104 + * R7 — "an external URL was never a managed file"). + * + * This seed used to write `data:image/svg+xml,…` here. Two lineages produced + * that: first picsum.photos URLs (a wall of broken images offline), then an + * inline SVG to remove the network dependency. Both are the same category + * error — a picture inlined into a slot that means "a file the platform + * manages". The cost was not cosmetic: every fresh datastore admitted 10 + * off-shape values during its own first boot, and `attestFreshDatastore` + * (#4769) will not certify a contract the boot has already broken — so + * `adr-0104-file-references` could NEVER auto-attest on a new install of the + * reference app, and the gate stayed open on day one. + * + * Nor can a seed honestly mint the right value. A conforming `cover` needs a + * real committed `sys_file` row (bytes in the storage backend, ownership + * recorded in `ref_object`/`ref_id`/`ref_field`); a shape-valid id with no row + * behind it is worse than the data URI, because ADR-0104's own reconciliation + * (`os migrate files-to-references`) classes it `unowned_reference` — a + * BLOCKING discrepancy — and because "fabricate an id that looks right" is + * exactly the pattern the reference app must not teach. + * + * So `cover` stays DECLARED (the image field, and `gallery.coverField: 'cover'` + * on the task views) and unseeded: it is populated by uploading a cover, which + * is how a managed file is meant to come into existence. The Gallery collapses + * its cover area when no record carries one, so the cards render as a clean, + * information-dense empty state rather than broken images. */ -function placeholderCover(seed: number, color: string): string { - const svg = `${seed}`; - return `data:image/svg+xml,${encodeURIComponent(svg)}`; -} const accounts = defineSeed(Account, { mode: 'upsert', @@ -131,16 +156,16 @@ const tasks = defineSeed(Task, { mode: 'upsert', externalId: 'title', records: [ - { cover: placeholderCover(1, '#10B981'), title: 'Audit current IA', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 8, progress: 100, done: true, created_at: cel`daysAgo(20)`, start_date: cel`daysAgo(20)`, end_date: cel`daysAgo(18)`, due_date: cel`daysAgo(18)`, location: { lat: 47.6062, lng: -122.3321 } }, - { cover: placeholderCover(2, '#8B5CF6'), title: 'Design system', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'in_review', priority: 'high', estimate_hours: 24, progress: 80, done: false, created_at: cel`daysAgo(14)`, start_date: cel`daysAgo(12)`, end_date: cel`daysFromNow(2)`, due_date: cel`daysFromNow(2)`, location: { lat: 37.7749, lng: -122.4194 } }, - { cover: placeholderCover(3, '#F59E0B'), title: 'Build homepage', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'in_progress', priority: 'high', estimate_hours: 40, progress: 45, done: false, created_at: cel`daysAgo(8)`, start_date: cel`daysAgo(6)`, end_date: cel`daysFromNow(10)`, due_date: cel`daysFromNow(10)`, location: { lat: 40.7128, lng: -74.0060 } }, - { cover: placeholderCover(4, '#3B82F6'), title: 'SEO migration plan', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'todo', priority: 'medium', estimate_hours: 16, progress: 0, done: false, created_at: cel`daysAgo(3)`, start_date: cel`daysFromNow(5)`, end_date: cel`daysFromNow(15)`, due_date: cel`daysFromNow(15)`, location: { lat: 30.2672, lng: -97.7431 } }, - { cover: placeholderCover(5, '#94A3B8'), title: 'Content backlog', project: 'Website Relaunch', assignee: 'grace@example.com', status: 'backlog', priority: 'low', estimate_hours: 12, progress: 0, done: false, created_at: cel`daysAgo(2)`, due_date: cel`daysFromNow(30)`, location: { lat: 41.8781, lng: -87.6298 } }, - { cover: placeholderCover(6, '#F59E0B'), title: 'Ingest pipeline', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_progress', priority: 'urgent', estimate_hours: 60, progress: 55, done: false, created_at: cel`daysAgo(40)`, start_date: cel`daysAgo(35)`, end_date: cel`daysFromNow(20)`, due_date: cel`daysFromNow(20)`, location: { lat: 39.7392, lng: -104.9903 } }, - { cover: placeholderCover(7, '#8B5CF6'), title: 'Warehouse schema', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_review', priority: 'high', estimate_hours: 30, progress: 90, done: false, created_at: cel`daysAgo(25)`, start_date: cel`daysAgo(22)`, end_date: cel`daysFromNow(3)`, due_date: cel`daysFromNow(3)`, location: { lat: 42.3601, lng: -71.0589 } }, - { cover: placeholderCover(8, '#3B82F6'), title: 'PII access review', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'todo', priority: 'urgent', estimate_hours: 20, progress: 0, done: false, created_at: cel`daysAgo(5)`, start_date: cel`daysFromNow(2)`, end_date: cel`daysFromNow(12)`, due_date: cel`daysFromNow(12)`, location: { lat: 38.9072, lng: -77.0369 } }, - { cover: placeholderCover(9, '#94A3B8'), title: 'Evidence collection', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'backlog', priority: 'medium', estimate_hours: 18, progress: 0, done: false, created_at: cel`daysAgo(1)`, due_date: cel`daysFromNow(25)`, location: { lat: 34.0522, lng: -118.2437 } }, - { cover: placeholderCover(10, '#10B981'), title: 'App wireframes', project: 'Mobile App', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 16, progress: 100, done: true, created_at: cel`daysAgo(10)`, start_date: cel`daysAgo(10)`, end_date: cel`daysAgo(6)`, due_date: cel`daysAgo(6)`, location: { lat: 45.5152, lng: -122.6784 } }, + { title: 'Audit current IA', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 8, progress: 100, done: true, created_at: cel`daysAgo(20)`, start_date: cel`daysAgo(20)`, end_date: cel`daysAgo(18)`, due_date: cel`daysAgo(18)`, location: { lat: 47.6062, lng: -122.3321 } }, + { title: 'Design system', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'in_review', priority: 'high', estimate_hours: 24, progress: 80, done: false, created_at: cel`daysAgo(14)`, start_date: cel`daysAgo(12)`, end_date: cel`daysFromNow(2)`, due_date: cel`daysFromNow(2)`, location: { lat: 37.7749, lng: -122.4194 } }, + { title: 'Build homepage', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'in_progress', priority: 'high', estimate_hours: 40, progress: 45, done: false, created_at: cel`daysAgo(8)`, start_date: cel`daysAgo(6)`, end_date: cel`daysFromNow(10)`, due_date: cel`daysFromNow(10)`, location: { lat: 40.7128, lng: -74.0060 } }, + { title: 'SEO migration plan', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'todo', priority: 'medium', estimate_hours: 16, progress: 0, done: false, created_at: cel`daysAgo(3)`, start_date: cel`daysFromNow(5)`, end_date: cel`daysFromNow(15)`, due_date: cel`daysFromNow(15)`, location: { lat: 30.2672, lng: -97.7431 } }, + { title: 'Content backlog', project: 'Website Relaunch', assignee: 'grace@example.com', status: 'backlog', priority: 'low', estimate_hours: 12, progress: 0, done: false, created_at: cel`daysAgo(2)`, due_date: cel`daysFromNow(30)`, location: { lat: 41.8781, lng: -87.6298 } }, + { title: 'Ingest pipeline', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_progress', priority: 'urgent', estimate_hours: 60, progress: 55, done: false, created_at: cel`daysAgo(40)`, start_date: cel`daysAgo(35)`, end_date: cel`daysFromNow(20)`, due_date: cel`daysFromNow(20)`, location: { lat: 39.7392, lng: -104.9903 } }, + { title: 'Warehouse schema', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_review', priority: 'high', estimate_hours: 30, progress: 90, done: false, created_at: cel`daysAgo(25)`, start_date: cel`daysAgo(22)`, end_date: cel`daysFromNow(3)`, due_date: cel`daysFromNow(3)`, location: { lat: 42.3601, lng: -71.0589 } }, + { title: 'PII access review', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'todo', priority: 'urgent', estimate_hours: 20, progress: 0, done: false, created_at: cel`daysAgo(5)`, start_date: cel`daysFromNow(2)`, end_date: cel`daysFromNow(12)`, due_date: cel`daysFromNow(12)`, location: { lat: 38.9072, lng: -77.0369 } }, + { title: 'Evidence collection', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'backlog', priority: 'medium', estimate_hours: 18, progress: 0, done: false, created_at: cel`daysAgo(1)`, due_date: cel`daysFromNow(25)`, location: { lat: 34.0522, lng: -118.2437 } }, + { title: 'App wireframes', project: 'Mobile App', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 16, progress: 100, done: true, created_at: cel`daysAgo(10)`, start_date: cel`daysAgo(10)`, end_date: cel`daysAgo(6)`, due_date: cel`daysAgo(6)`, location: { lat: 45.5152, lng: -122.6784 } }, ], }); diff --git a/examples/app-showcase/src/security/capabilities.ts b/examples/app-showcase/src/security/capabilities.ts index d7646b626c..fe1c9bb5fb 100644 --- a/examples/app-showcase/src/security/capabilities.ts +++ b/examples/app-showcase/src/security/capabilities.ts @@ -27,12 +27,34 @@ import { defineCapability } from '@objectstack/spec'; * organization. Granted to Operations (see permission-sets.ts); a future export * endpoint/action would gate itself with `requiredPermissions: * ['showcase.export_data']`. + * + * `packageId` is the ADR-0086 D3 AUTHOR-DECLARED provenance — the documented + * fallback the seeder reads when the registry has not stamped `_packageId` + * (`cap._packageId ?? cap.packageId` in `bootstrapDeclaredCapabilities`). + * Without an owning package the declaration is NOT materialized into + * `sys_capability` (a `managed_by:'package'` row with no `package_id` would + * make uninstall undefined), which left `OpsPermissionSet` granting a + * capability that never existed — one boot `warn` and an inert security + * declaration. + * + * Why it is authored here rather than inferred: the registry stamp never + * reaches an app-declared capability, because `capabilities` is missing from + * the metadata-array key list the ObjectQL engine registers a stack's + * collections through — so the "fallback" is in practice mandatory. That is a + * PLATFORM gap, filed as #4967 (together with the sharper half: a refused + * declaration is still counted as declared, which suppresses the back-compat + * derivation and leaves the capability existing nowhere), not something the + * showcase should work around. Declaring `packageId` is the spec's own + * sanctioned entry point, so this both fixes the app and demonstrates the + * field; when the platform stamps `_packageId` that takes precedence and this + * stays consistent with it. */ export const ExportDataCapability = defineCapability({ name: 'showcase.export_data', label: 'Export Showcase Data', description: 'Bulk-export showcase records (accounts, invoices) to CSV/XLSX.', scope: 'org', + packageId: 'com.example.showcase', }); export const allCapabilities = [ExportDataCapability]; diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts new file mode 100644 index 0000000000..9ccf6afa2a --- /dev/null +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -0,0 +1,406 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { readdirSync, readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import stack from '../objectstack.config.js'; +import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; +import { FILE_REFERENCE_TYPES, valueSchemaFor } from '@objectstack/spec/data'; +import { healthFor, sweepProjectHealth, bindShowcaseJobRuntime } from '../src/automation/jobs/index.js'; + +/** + * #4774 / #4888 / #4891 — the showcase's DECLARED-BUT-INERT wirings. + * + * Each guard below pins one declaration the runtime was accepting at authoring + * time and then quietly not honouring, announced only by a line in the boot + * warning block. They are the same bug class as `no-startup-warnings.test.ts` + * (#3420) — the reference app must not train anyone to skim warnings — but here + * the warning was the *symptom*: a nightly job that never ran, a permission set + * granting a capability that never materialized, a retry key scheduled for + * removal, and seed values the platform's own migration gate rejects. + */ + +/** Package root — vitest runs with the example as cwd (same as coverage.test.ts). */ +const SRC_ROOT = `${process.cwd()}/src`; + +/** Every authored `.ts` under `src/` — the surface a source-text guard scans. */ +function sourceFiles(dir: string = SRC_ROOT): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = `${dir}/${entry.name}`; + if (entry.isDirectory()) out.push(...sourceFiles(full)); + else if (entry.name.endsWith('.ts')) out.push(full); + } + return out; +} + +/** + * Source text with comments removed, so a source-scan guard judges CODE. + * Documentation must stay free to name a retired key (this file's own comments + * do, and so do the ones explaining the rename) without tripping the guard that + * bans authoring it. Block comments go first; then whole-line `//` comments — + * never a trailing `//`, which would eat the `//` in a URL inside a string. + */ +function codeOf(file: string): string { + return readFileSync(file, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('\n') + .filter((line: string) => !line.trimStart().startsWith('//')) + .join('\n'); +} + +/** Every `functions` entry, whichever spelling it was authored in. */ +function functionNames(): string[] { + const fns = (stack as { functions?: unknown }).functions; + if (Array.isArray(fns)) { + return fns.map((f: { name?: string }) => f?.name).filter((n): n is string => typeof n === 'string'); + } + if (fns && typeof fns === 'object') return Object.keys(fns as Record); + return []; +} + +function functionEntry(name: string): unknown { + const fns = (stack as { functions?: Record }).functions; + return fns && !Array.isArray(fns) ? fns[name] : undefined; +} + +// ─────────────────────────────────────────────────────────────────────────── +// 1. Scheduled job — the handler has to EXIST +// ─────────────────────────────────────────────────────────────────────────── +describe('declarative jobs resolve their handler (#4774 ①)', () => { + const jobs = ((stack as { jobs?: unknown[] }).jobs ?? []) as Array<{ + name: string; + handler: string; + enabled?: boolean; + }>; + + it('declares at least one job (the coverage claim)', () => { + expect(jobs.length).toBeGreaterThan(0); + }); + + for (const job of jobs) { + it(`${job.name}: handler '${job.handler}' is a key of defineStack({ functions })`, () => { + // This is the exact lookup `AppPlugin` performs on `kernel:ready` + // (`collectBundleFunctions(bundle)[job.handler]`). A miss there is the + // "job handler not found in bundle.functions — skipping" WARN, and the + // job is registered but NEVER RUNS. + expect( + functionNames(), + `job '${job.name}' names handler '${job.handler}', which no functions entry provides`, + ).toContain(job.handler); + }); + + it(`${job.name}: handler '${job.handler}' is callable`, () => { + const entry = functionEntry(job.handler) as { handler?: unknown } | ((...a: never[]) => unknown); + const callable = typeof entry === 'function' ? entry : entry?.handler; + expect(typeof callable).toBe('function'); + }); + } + + it('every functions entry is authored in a form `objectstack build` can carry', () => { + // `objectstack build` LOWERS each inline callable to a serialisable string + // ref before the stack is parsed, and `FlowFunctionEntrySchema` accepts a + // bare callable, a declaration whose `handler` is a CALLABLE, or a bare + // string ref — but NOT a declaration whose handler has been lowered to a + // string, which is exactly what the CLI emits for the declared form + // (`{ handler: fn, effect: 'writes' }`, #4396). So authoring the declared + // form here builds green from source and fails `pnpm build` with + // `functions: invalid_union`. Filed as #4976. + // + // Pinning the bare form keeps that failure out of the reference app until + // the schema accepts the lowered declaration. Delete this guard — don't + // work around it — when #4976 lands. + const declared = functionNames().filter((name) => typeof functionEntry(name) !== 'function'); + expect( + declared, + `declared-form functions entry/entries cannot survive \`objectstack build\` (#4976): ${declared.join(', ')}`, + ).toEqual([]); + }); +}); + +describe('sweepProjectHealth computes health from burn vs progress (#4774 ①)', () => { + it('is green when spending tracks delivery', () => { + expect(healthFor({ budget: 150_000, spent: 60_000, taskProgress: [100, 80, 45, 0, 0] })).toBe('green'); + }); + + it('is yellow when spending drifts ahead of delivery', () => { + // burn 0.60, done 0.40 → drift 0.20 + expect(healthFor({ budget: 100_000, spent: 60_000, taskProgress: [40] })).toBe('yellow'); + }); + + it('is red when spending runs far ahead of delivery', () => { + // burn 0.978, no delivered progress → drift 0.978 + expect(healthFor({ budget: 90_000, spent: 88_000, taskProgress: [0, 0] })).toBe('red'); + }); + + it('is red when over budget regardless of progress', () => { + expect(healthFor({ budget: 10_000, spent: 12_000, taskProgress: [100] })).toBe('red'); + }); + + it('treats a missing budget as no burn rather than a divide-by-zero', () => { + expect(healthFor({ budget: undefined, spent: 5_000, taskProgress: [] })).toBe('green'); + }); + + it('reads numeric columns that arrive as strings', () => { + expect(healthFor({ budget: '100000', spent: '90000', taskProgress: [10] })).toBe('red'); + }); + + it('sweeps only in-play projects and writes only what changed', async () => { + const projects = [ + { id: 'p_active', status: 'active', health: 'green', budget: 90_000, spent: 88_000 }, + { id: 'p_hold', status: 'on_hold', health: 'red', budget: 100_000, spent: 10_000 }, + ]; + const tasks = [ + { project: 'p_active', progress: 0 }, + { project: 'p_active', progress: 0 }, + { project: 'p_hold', progress: 90 }, + ]; + const reads: Array<{ object: string; query: any }> = []; + const writes: Array> = []; + + bindShowcaseJobRuntime({ + ql: { + find: async (object: string, query: any) => { + reads.push({ object, query }); + if (object === 'showcase_project') return projects; + if (object === 'showcase_task') return tasks; + return []; + }, + update: async (_object: string, data: Record) => { + writes.push(data); + return data; + }, + }, + }); + + await sweepProjectHealth({ jobId: 'showcase_health_sweep' }); + + // Only `active` / `on_hold` are read — settled projects are not relitigated. + expect(reads[0]?.object).toBe('showcase_project'); + expect(reads[0]?.query?.where?.status?.$in).toEqual(['active', 'on_hold']); + // p_active: burn 0.978 vs done 0 → red (changed from green). + // p_hold: burn 0.10 vs done 0.90 → green (changed from red). + expect(writes).toEqual([ + { id: 'p_active', health: 'red' }, + { id: 'p_hold', health: 'green' }, + ]); + }); + + it('is a no-op when nothing changed', async () => { + const writes: unknown[] = []; + bindShowcaseJobRuntime({ + ql: { + find: async (object: string) => + object === 'showcase_project' + ? [{ id: 'p1', status: 'active', health: 'green', budget: 100, spent: 10 }] + : [{ project: 'p1', progress: 100 }], + update: async (_o: string, d: Record) => { + writes.push(d); + return d; + }, + }, + }); + await sweepProjectHealth({ jobId: 'showcase_health_sweep' }); + expect(writes).toEqual([]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 2. Capabilities — declared, granted, and MATERIALIZABLE +// ─────────────────────────────────────────────────────────────────────────── +describe('declared capabilities carry an owning package (#4774 ②)', () => { + const capabilities = ((stack as { capabilities?: unknown[] }).capabilities ?? []) as Array<{ + name: string; + packageId?: string; + }>; + + it('declares at least one package capability', () => { + expect(capabilities.length).toBeGreaterThan(0); + }); + + for (const cap of capabilities) { + it(`${cap.name}: resolves an owning package`, () => { + // `bootstrapDeclaredCapabilities` reads `cap._packageId ?? cap.packageId` + // and REFUSES to materialize a capability with neither (a + // `managed_by:'package'` row with no `package_id` makes uninstall + // undefined — ADR-0086 D3). The registry stamp never reaches an + // app-declared capability today, so the author-declared fallback is what + // has to be present. + const owner = (cap as { _packageId?: string })._packageId ?? cap.packageId; + expect( + owner, + `capability '${cap.name}' has no owning package — it would not be materialized into sys_capability`, + ).toBeTruthy(); + }); + + it(`${cap.name}: is owned by this app`, () => { + const owner = (cap as { _packageId?: string })._packageId ?? cap.packageId; + expect(owner).toBe((stack as { manifest?: { id?: string } }).manifest?.id); + }); + } + + it('every granted system permission is a capability that will exist', () => { + const declared = new Set(capabilities.map((c) => c.name)); + const dangling: string[] = []; + for (const set of ((stack as { permissions?: unknown[] }).permissions ?? []) as Array<{ + name: string; + systemPermissions?: string[]; + }>) { + for (const perm of set.systemPermissions ?? []) { + if (PLATFORM_CAPABILITY_NAMES.has(perm)) continue; + if (declared.has(perm)) continue; + dangling.push(`${set.name} → ${perm}`); + } + } + // A permission set that grants a capability nothing declares reads as + // enforcement and grants nothing — a security declaration that is inert. + expect(dangling, `permission set(s) grant undeclared capabilities: ${dangling.join(', ')}`).toEqual([]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 3. Retry policy — canonical spelling only +// ─────────────────────────────────────────────────────────────────────────── +describe('retry policies use the canonical key (#4774 ③)', () => { + /** Every `retry` / `retryPolicy` block reachable from the stack, with a path. */ + function retryBlocks(): Array<{ path: string; block: Record }> { + const out: Array<{ path: string; block: Record }> = []; + const walk = (value: unknown, path: string): void => { + if (Array.isArray(value)) { + value.forEach((v, i) => walk(v, `${path}[${i}]`)); + return; + } + if (!value || typeof value !== 'object') return; + for (const [key, child] of Object.entries(value as Record)) { + const childPath = `${path}.${key}`; + if ((key === 'retry' || key === 'retryPolicy') && child && typeof child === 'object' && !Array.isArray(child)) { + out.push({ path: childPath, block: child as Record }); + } + walk(child, childPath); + } + }; + walk((stack as { flows?: unknown }).flows, 'flows'); + walk((stack as { jobs?: unknown }).jobs, 'jobs'); + walk((stack as { hooks?: unknown }).hooks, 'hooks'); + return out; + } + + it('the stack actually declares retry policies (guard is not vacuous)', () => { + expect(retryBlocks().length).toBeGreaterThan(0); + }); + + it("no SOURCE file still spells the base delay 'retryDelayMs'", () => { + // Deliberately reads the source text, not `stack` — the parsed stack CANNOT + // answer this question. `retryDelayMs` is tombstoned in `RetryPolicySchema` + // (17.0.0, #4661) and only keeps working because the + // `retry-policy-converged` conversion rewrites it during `defineStack`, so + // by the time a test can inspect the object the retired spelling is already + // gone and every assertion over it passes vacuously. The thing that has to + // change — and that stops loading when the conversion retires in protocol + // 18 — is what the author wrote. + const offenders = sourceFiles() + .filter((file) => /\bretryDelayMs\s*:/.test(codeOf(file))) + .map((file) => file.slice(SRC_ROOT.length + 1)); + expect( + offenders, + `retired 'retryDelayMs' (rename to 'backoffMs') in: ${offenders.join(', ')}`, + ).toEqual([]); + }); + + it("every block states its base delay as 'backoffMs'", () => { + for (const { path, block } of retryBlocks()) { + expect(typeof block.backoffMs, `${path} has no backoffMs`).toBe('number'); + } + }); + + it("keeps 'maxRetryDelayMs', which the convergence did NOT rename", () => { + // Guards the other direction: `maxRetryDelayMs` is a canonical key of + // `RetryPolicySchema` (the ceiling for one backoff delay), not a casualty + // of `retry-policy-converged`. Dropping it "for symmetry" would be a + // behaviour change, so the showcase keeps demonstrating it. + const withCeiling = retryBlocks().filter(({ block }) => 'maxRetryDelayMs' in block); + expect(withCeiling.length).toBeGreaterThan(0); + for (const { path, block } of withCeiling) { + expect(typeof block.maxRetryDelayMs, `${path}.maxRetryDelayMs`).toBe('number'); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 4. Seed values — ADR-0104 value shapes +// ─────────────────────────────────────────────────────────────────────────── +describe('seed values satisfy the ADR-0104 stored contract (#4774 ④ / #4891)', () => { + const objects = ((stack as { objects?: unknown[] }).objects ?? []) as Array<{ + name: string; + fields?: Record; + }>; + const seeds = ((stack as { data?: unknown[] }).data ?? []) as Array<{ + object: string; + externalId?: string; + records?: Array>; + }>; + + it('seeds at least one dataset (guard is not vacuous)', () => { + expect(seeds.length).toBeGreaterThan(0); + }); + + it('no seeded file-class value is off-shape', () => { + // The engine tallies every off-shape value it admits, and + // `attestFreshDatastore` (#4769) refuses to certify a migration the SAME + // BOOT has already contradicted. One bad `cover` therefore costs a brand + // new datastore the `adr-0104-file-references` attestation permanently — + // the gate stays open on day one of a fresh install of the reference app. + const offenders: string[] = []; + for (const seed of seeds) { + const object = objects.find((o) => o.name === seed.object); + if (!object?.fields) continue; + const fileFields = Object.entries(object.fields) + .filter(([, def]) => def?.type && FILE_REFERENCE_TYPES.has(def.type)) + .map(([name, def]) => [name, def] as const); + if (fileFields.length === 0) continue; + for (const record of seed.records ?? []) { + for (const [fieldName, def] of fileFields) { + const value = record[fieldName]; + if (value === undefined || value === null) continue; + const parsed = valueSchemaFor(def as { type: string }, 'stored').safeParse(value); + if (!parsed.success) { + const key = String(record[seed.externalId ?? 'name'] ?? '?'); + offenders.push(`${seed.object}.${fieldName} ('${key}')`); + } + } + } + } + expect( + offenders, + `seeded file-class value(s) are not an opaque sys_file id: ${offenders.join(', ')}`, + ).toEqual([]); + }); + + it('no seed smuggles an inline data: / http(s): image', () => { + // The narrower, more legible half of the rule above: whatever the field + // type, a seed must never carry image BYTES or an external link where the + // platform expects a managed file (ADR-0104 R7). + const offenders: string[] = []; + for (const seed of seeds) { + for (const record of seed.records ?? []) { + for (const [field, value] of Object.entries(record)) { + if (typeof value !== 'string') continue; + if (/^data:image\//i.test(value) || /^https?:\/\/[^ ]*(picsum|placehold)/i.test(value)) { + offenders.push(`${seed.object}.${field}`); + } + } + } + } + expect(offenders, `inline/remote image value(s) in seed data: ${offenders.join(', ')}`).toEqual([]); + }); + + it('still DECLARES the image field and its gallery binding', () => { + // Dropping the bad data must not quietly drop the coverage: `cover` is + // still an image field and the task gallery still binds to it, so the + // capability is demonstrated — it is populated by uploading a cover, which + // is how a managed file is meant to come into existence. + const task = objects.find((o) => o.name === 'showcase_task'); + expect(task?.fields?.cover?.type).toBe('image'); + const bound = JSON.stringify((stack as { views?: unknown }).views ?? []).includes('"coverField":"cover"'); + expect(bound, 'no task view binds gallery.coverField to `cover`').toBe(true); + }); +}); diff --git a/examples/app-showcase/test/node-shim.d.ts b/examples/app-showcase/test/node-shim.d.ts index dc22e7b3ed..a6ca0ddfa7 100644 --- a/examples/app-showcase/test/node-shim.d.ts +++ b/examples/app-showcase/test/node-shim.d.ts @@ -7,6 +7,11 @@ declare module 'node:fs' { export function existsSync(path: string): boolean; + export function readFileSync(path: string, encoding: 'utf8'): string; + export function readdirSync( + path: string, + options: { withFileTypes: true }, + ): Array<{ name: string; isDirectory(): boolean }>; } declare const process: { cwd(): string };