master
${ noResults }
157 Commits (29dcd6b8cec60de4370f74da57f5ccc70c4ef902)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
29dcd6b8ce |
Bloque L1 — prefs foundation: capability libs + Source<T> port
Introduce the pure preference layer and the framework-wide value port that the
runtime layers (`arts/prefs`, format, frontend) consume.
- `libs/reactive` gains `Source<T>` — a small `{ get, onChange? }` port any
artifact uses to observe an external value (locale, currency, theme, …).
- `libs/locale` extracts the lookup-style `matchLocale` helper plus aliases
`LocaleSource` to `Source<string>`. Tests added for the four-step
exact / lang+script / lang+region / lang priority.
- New per-domain libs (`currency`, `density`, `direction`, `motion`, `theme`,
`timezone`, `units`) own their own primitives, capability sources and pure
helpers (`*FromLocale(s)`, `directionFromLanguage`, `resolveTheme`,
`resolveMotion`). The currency catalogue + region table moved here from
`arts/format/currency`.
- `libs/prefs` is the pure preference layer: `PrefsCapabilities`,
`PrefsEnvironment`, `PrefsIntent`, `PrefsEffective`, intent validation
(with stable `PrefsValidationFailure` codes) and the parallel-projection
resolver. Each `effective` field is now an INDEPENDENT projection of
`environment.locales[]` against its own capability catalog — `language`
and `locale` are split (i18n catalog vs regional formatting); `currency`,
`unitSystem`, `direction` derive per-dimension instead of from a single
`effective.locale` anchor.
- `arts/format` and `arts/frontend` migrated to the new `Source<T>` shape:
`localeSource?.getLocale()` → `localeSource?.get()` and
`onLocaleChange?` → `onChange?`. `arts/format/currency/locale-currencies`
and `arts/format/units/locale-defaults` collapse to thin re-exports of
their `libs/*` counterparts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
178e76bfd3 |
Bloque J2 revert — drop frontend runtime validators
Removes the `VALID_FRONTEND_DENSITIES`/`VALID_FRONTEND_MODES`/ `VALID_FRONTEND_DIRS` const triplet introduced by Bloque J2. The matching `assertValidFrontendValue` helper and its three call sites in `active-frontend.svelte.ts` were already cleaned up earlier in the working tree. Validation moves to `libs/prefs/validate-intent.ts` once the prefs artifact lands — the design at `src/arts/prefs/README.md` puts every `setIntent(...)` write through one shared validator instead of spreading per-art runtime guards. J1 (lang `SvelteSet → Set`) and J3 (sium `CodeError` migration) stay; only the J2 portion of the combined commit is reverted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
191a834a67 |
Revert "Bloque I1+I2 — frontend snapshot() + persistFrontendPreferences preset"
This reverts commit
|
5 months ago |
|
|
40da4def72 |
Bloque K3 — perm decision audit sink
`EnginePermsOptions.onDecision?: PermDecisionAuditSink` lets hosts
forward every `check()` decision to an audit pipeline (a database, an
event bus, S3, etc.). The reference SQL schema's
`permission_decision_audit` table is one such consumer — the engine
gives the host the data, the host writes wherever its compliance
needs.
The sink is awaited so DB writes that need to commit before the
request continues block correctly. Errors thrown by the sink are
caught and emitted as the new `perm.server.audit_failed` diagnostic
(LogLevel.ERROR) — an audit failure cannot turn a granted permission
into a denial or vice versa. Hosts wire alerts on that event.
`EnginePermsOptions.clock?: { now }` controls the `settledAt`
timestamp on audit entries — defaults to `Date.now`, hosts wire
`core.timers.clock` for deterministic audit timestamps in tests.
Test covers (a) the sink receives every decision with `settledAt` from
the injected clock, (b) sink errors are swallowed and the decision
still returns the expected effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
5d6777bfa4 |
Bloque J1+J2+J3 — P3 polish: lang Set, frontend validators, sium CodeError
J1 — `arts/lang/active-lang`: `localeListeners` migrates from `SvelteSet` to plain `Set`. Listeners are notified manually via `forEach`, never rendered as derived state — `SvelteSet` would re- render every downstream component on add/remove with zero upside. J2 — `arts/frontend`: `setMode`, `setDir`, `setDensity` validate their input against the closed string-union in DEV. Typed callers still get the compile-time error first; the runtime guard catches formless inputs (HTML form selects, untyped IPC, untyped JS imports) with a domain-specific `TypeError` instead of silently writing an unrecognized value to a `data-*` attribute. PROD is a no-op. J3 — `arts/sium`: complete the `CodeError` migration. Replaces every `throw new TypeError(SIUM_ERRORS.X)` site with a typed class: - `SiumEncodeExpectsObjectError` (object/discriminated encode) - `SiumEncodeExpectsArrayError` (array encode) - `SiumEncodeNoMatchError` (discriminated encode without match) - `SiumLazyResolvingError` (lazy() accessed mid-resolution) Each carries its `ErrCode` and is exported from the public surface with matching `is*Error` type guards. Existing `SiumValidationError`, `SiumAsyncSchemaError` and `SiumDiscriminatedUnionError` were already typed and stay as-is. The `SIUM_ERRORS` legacy catalogue keeps the diagnostic message strings (`VALIDATION_FAILED`, `RESOLVE_FALLBACK`) used by the diagnostics layer — those are not thrown errors, they are catalog entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
e9eb69dbd4 |
Bloque I5 — session broadcastChannel: false explicit opt-out
`EngineSessionOptions.broadcastChannel` now accepts `string | false` in addition to `undefined`. Passing `false` skips the `BroadcastChannel` setup entirely — useful for: - privacy-strict modes that don't want any cross-tab signal - tests that want deterministic identity (no cross-tab race) without having to rely on `BroadcastChannel` being undefined - SSR / Worker environments Storage-driven sync via `localStorage`'s `storage` event still runs when the storage adapter exposes `onChange` — only the explicit channel post is skipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
b1e73f4586 |
Bloque I3+I4 — format LRU cap + unified locale source
I3 — `Intl.NumberFormat` cache in currency now caps at 256 entries with LRU eviction. The cache was module-global and unbounded; long-running multi-locale / multi-currency apps (financial dashboards, i18n test matrices) accumulated formatter instances forever. Map iteration order is insertion-order so the LRU is implemented as "delete on hit, set on hit, evict the first key when full" — no extra structure. I4 — `createActiveFormatLocaleSource` now maintains its own listener set, so `setLocale()` fires every subscribing submodule through one notification. Previously the parent's `setLocale` only mutated the internal `currentLocale` and the parent then re-called `setLocale` on every submodule manually — two notifications per change. The unified source delivers exactly one. `createActiveFormat.setLocale()` no longer needs to fan out to numbers/currency/units/dates by hand. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
7fb071d22e |
Bloque I1+I2 — frontend snapshot() + persistFrontendPreferences preset
I1 — `ActiveFrontend.snapshot(): FrontendSnapshot` returns every
observable preference resolved at call time
(`{ locale, dir, theme, mode, reducedMotion, reducedSound, density }`).
Computed fresh on each call from the live state — useful for logger
context, persistence, devtools, snapshot diffing.
I2 — `applyPersistFrontendPreferences(App, options?)` preset round-
trips the frontend preferences through `App.storage`. Replays a
persisted snapshot at attach time, writes back on every preference
change, optionally filters which keys to persist. The detacher cleanly
stops persisting and disposes the storage entry — idempotent.
Cross-tab sync rides on the storage adapter's `onChange` (the
`storage` event for `localAdapter`, `BroadcastChannel` for
`broadcastAdapter`); the preset guards against re-entrant writes when
its own change triggers an external echo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
3ec92acdc3 |
Bloque H6 — http per-attempt observability
New diagnostic event `http.attempt_completed` carries `attempt`, `durationMs` (wall-clock from request start to response settle), `ok`, `status?` and `error?`. Lets dashboards compute p50/p95 latency without inferring it from the request + retrying events. `http.retrying` is now emitted AFTER the wait so it can include `actualDelayMs` — the observed time between attempts may differ from the computed `retryDelay` when an abort cuts the wait short or a `beforeRetry` hook takes noticeable time. `HttpDiagnosticMeta` gains `durationMs`, `ok`, `actualDelayMs` and `abortReason` slots. The pre-existing test that asserted exactly 1 DEBUG log per request now asserts 2 (REQUEST + ATTEMPT_COMPLETED) — that is the change in shape this block introduces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
e40fc7fa1c |
Bloque H4 — cache memory adapter routes prod warning through logger
`MemoryCacheAdapterOptions.logger?: Logger` lets the adapter's
production warning flow through the framework's logger (and from
there, every transport the host has wired) instead of always landing
on `console.warn`.
Resolution order:
1. `onProductionWarning?` callback (caller has full control)
2. `logger?.warn(CACHE_MODULE, message)` (framework path, picks up
Sentry/Datadog/Loki/whatever the host uses)
3. `console.warn` (legacy fallback, kept for callers that do not
wire either of the above)
No behavior change for existing apps — the new option is opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
0c15e0b94c |
Bloque H3 — official createFakeTimerClock() exported from \$timer
The `engine-timers.test.ts` file already had a `createFakeClock` for internal use, but every artifact under test that accepts a `clock` injection (storage, format, logger, http, session auto-refresh, …) was rolling its own. Promoting the helper to the public surface gives the ecosystem a single source of deterministic time for tests. `createFakeTimerClock(start = 0): FakeTimerClock` exposes `advanceBy(ms)`, `advanceTo(ms)` and `pendingCount()` on top of the shared `TimerClock` shape. Microtask flushes between fired entries keep awaited promises inside scheduled callbacks resolved before time moves on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
5152b23c6b |
Bloque H1+H2 — logger onInternalError + clock/idFactory injection
H1 — `LoggerOptions.onInternalError?: (info) => void` lets enterprise
hosts capture transport failures somewhere other than `console.error`
(Sentry's captureException, an audit pipeline, etc.). When defined, the
engine routes the failure-path notification to the hook instead of
`console.error`. The synthetic failure entry that flows to remaining
transports is independent of the hook — it always dispatches.
H2 — `LoggerOptions.clock?: { now }` and `LoggerOptions.idFactory?:
() => string` make timestamps and entry ids deterministic for tests and
runtimes with strict time discipline. The Logger is created BEFORE
`App.Timers`, so this is opt-in injection (not App-wired). Default
`Date.now` is late-bound through a closure so existing
`vi.spyOn(Date, 'now')` test patterns keep working.
Tests cover both injections plus the fallback path (no
`onInternalError` → `console.error` is still called).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
21ca220fef |
Bloque G5+G6 — auth anti-enumeration + redaction contracts
G5 — `requestPasswordReset` and `requestEmailVerification` now return a
public response shape that is indistinguishable for known and unknown
identifiers. A real bug surfaced while writing the test: the
known-identifier branch returned `{ ok: true, expiresAt }` while the
unknown branch returned `{ ok: true }`, which let any caller enumerate
accounts by checking the field's presence. Both flows now drop
`expiresAt` from the public response — internal flow records keep it,
the wire never exposes it. `AuthFlowPublicResult.expiresAt` stays in
the type as a forward-compat slot for authenticated trigger flows.
Tests pin: (a) shape parity between known/unknown, (b) no flow
created for unknown identifier, (c) no mail sent for unknown
identifier, (d) re-requesting verification on an already-verified
credential short-circuits silently.
G6 — Tests pin the redaction-by-design contract: every emitted
`AuthLogEntry` is searched for `password`, `identifier`, and `token`
substrings (in `data`/`meta`/`message`/etc.), and they must never
appear. Covers sign-up, sign-in, password-reset request and
failed sign-in. The framework's design enforces this through
`identifierHash`, `AuthRequestMeta` (hashes only) and `challengeHash`
— the tests guard against future code adding raw fields by accident.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
a4c3378d1a |
Bloque G3 — defineActivePerm wires App.http when declared
The perm client accepts `http?: EngineHttp` for transport, but the
service factory previously did not forward `App.http` automatically. An
app that declared both `http` and `perm` had to wire them together by
hand or pass `endpoint` + a custom `fetcher`.
`defineActivePerm` now declares `serviceDependencies: ['http']` and
forwards `App.http` to `createActivePerms({ http })` when:
- the caller did NOT pass `options.http` (explicit wins)
- AND did NOT pass `options.fetcher` (caller signaled their own
transport — leave `http` undefined to avoid double-wiring)
Apps without `http` declared keep working — `services.http` is
`undefined` and we leave the `http` slot empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
ddfc4dc6ea |
Bloque G1+G2 — perm preordered policies + per-decision provider memo
G1 — `createPermRuntime` sorts policies by priority once at construction instead of every decision. Policies are immutable for the runtime's lifetime; per-decision sorting was wasted work that scaled poorly with policy count. `combineEvaluatedPolicies` already assumed entries arrive in priority order, so the change is behavior-preserving. G2 — `DefaultPermEvaluator.evaluate(expr, context, memo?)` accepts an optional `PermEvaluatorMemo` (Map<string, unknown>) and threads it through every internal recursion. The runtime allocates one fresh memo per `evaluatePolicies` call, so concurrent matching policies asking for the same `actor.role` attribute or the same `member_of(team)` relation hit the providers exactly once per decision. Adjacent decisions get fresh memos — stale data never leaks across requests. Test covers (a) attribute provider called once across N policies in one decision, (b) relation provider called once across N policies in one decision, (c) two adjacent decisions allocate two memos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
5c92a5cc7a |
Bloque F5 — sium follows lang's active locale lazily
`createSiumResolver` now reads `lang.getLocale()` on every `resolve()` when the wired lang exposes that getter (i.e. `ActiveLang`). Pure `EngineLang` consumers fall back to the captured construction-time default — same behavior as before, no breakage. Closes the audit's P2: "Sium captures `defaultLocale` at construction; a later `Lang.setLocale(...)` was ignored unless the caller passed an explicit `locale` to every resolve()/resolveIssue() call". Test covers the lazy follow-through with a duck-typed `getLocale` shim over an `EngineLang`, so the test does not need to spin up the full `ActiveLang` Svelte runtime. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
529a93b813 |
Bloque F3+F4 — format Rates clock injection + storage dynamicEntry guard
F3 — `ActiveCurrencyOptions.ratesOptions` shorthand builds the rates
provider on the caller's behalf and threads the injected `clock` into
`createRates({ now })`. `ActiveFormatOptions.clock` propagates to the
currency submodule. The active-app `format` service factory now declares
`coreDependencies: ['timers']` and wires `core.timers.clock` so rate
expiration math runs through the same time source as the rest of the
ecosystem. Tests cover (a) clock-driven expiration of cached rates and
(b) `rates` (explicit provider) winning over `ratesOptions`.
F4 — `Storage.dynamicEntry()` now throws a domain-specific
`StorageDynamicEntryOutOfScopeError` when invoked outside a Svelte
component or `$effect.root` scope, instead of leaking Svelte's internal
`effect_orphan` error. The new error code, class and type guard are
exported from the `$storage` barrel. Storage's clock injection was
already wired through `defineActiveStorage` from a previous block —
no change needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
15737e0f37 |
Bloque F1+F2 — connection backoff random + reauth singleflight
F1 — `ConnectionReconnectOptions.random?: () => number` lets callers inject a deterministic source for backoff jitter, threaded through `computeBackoffDelay`. Default remains `Math.random` so existing apps are unaffected. Test covers maxAttempts, the new random injection (jitter +max and -max clamped to minDelay), the disabled case and the disposed/intentional-close gate. F2 — `runAuth()` singleflight in the connection request runtime: when an auth round is in flight, every concurrent caller awaits the same promise, so only one auth frame goes on the wire. Closes the gap where `session.changed` + `session.external_changed` could land back-to-back and produce two auth frames. Tested at the request-runtime level with fake ack registry + sender (integration-level testing of this through the mock transport is timing-flaky and adds no extra coverage). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
22aab00b7d |
Bloque E — compound ecosystem cross-actor isolation test
Wires real `createActiveCache` + `createEngineSession` + a stubbed `perm`
+ stubbed `connections` + `applyStandardOrca`, then drives the canonical
A→logout→B flow to confirm cache/perm/connection reactions fire on
session lifecycle transitions.
Findings while writing the test, documented in the file header:
- `SESSION_EVENT_IDENTITY_CHANGED` only fires on identity-state
transitions (`none` ↔ `anonymous` ↔ `identified`), not on in-place
`adopt(A) → adopt(B)`. The realistic cross-actor flow is therefore
`adopt → revoke → adopt`, which the suite exercises end-to-end.
- The orca dispatches reactions through `void (async () => { ... })()`
microtask runs; flushing fixed rounds is flaky. The test polls
`Orca.running` until idle, capped to avoid hangs.
Coverage: B sees no cache/perm of A after re-login; revoke clears the
cache + closes connections; reauth fires only on identity-state
transitions; detaching the preset stops the reactions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
c4b843ceca |
Bloque C5 (revert) — logger back to threshold-only filtering
Reverts the per-level enable map (`LevelsConfig` / `LevelConfig`) added in
|
5 months ago |
|
|
3567206fa3 |
Bloque C5 — logger gates by per-level enablement at the engine level
Audit P1/P2: transports already accepted `LevelsConfig`
(`{ [LogLevel.WARN]: { enabled: true } }`), but the engine itself
gated entries with a threshold (`if (lvl < state.level) return`).
Two filtering vocabularies for the same vocabulary; a 1.0 contract
should pick one.
Decision: align the engine on per-level enablement (the same
shape transports use). Threshold semantics stay as a shorthand —
`level: LogLevel.WARN` is equivalent to
`levels: levelsAtLeast(LogLevel.WARN)`. When both are set, `levels`
wins. Backward-compatible: existing apps that only pass `level`
get the exact same enabled set as before because the engine
projects the threshold into `enabledLevels` at boot.
Implementation:
- `LoggerOptions` gains `levels?: LevelsConfig`. Two helpers,
`buildEnabledLevelsFromThreshold(level)` and
`buildEnabledLevelsFromLevelsConfig(levels)`, project either form
into the runtime `Set<LogLevel>` the engine consults at the log
site.
- Engine state grows `enabledLevels: Set<LogLevel>`. The hot path
becomes `if (!state.enabledLevels.has(lvl)) return`.
- `setLevel(level)` keeps working — it rebuilds `enabledLevels`
from the new threshold.
Two regression tests pin the new behaviour: arbitrary subset via
`levelsAtLeast(LogLevel.WARN)`, and `levels` overriding `level`
when both are set.
Suite: 1515 / 1515 (+3 tests across logger and storage clock).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
6b6a96fb11 |
Bloque C4 — service factories wire `clock` from `App.Timers`
Audit P2: cache, perm and storage already accepted `clock` in
their engine options, but their `defineActive*` factories only
threaded `logger` from the core. App-composed apps therefore fell
back to `Date.now`-backed clocks for TTL math, decision-cache
expiration and envelope expiration — out of band with the rest of
the ecosystem.
- `defineActiveCache` now declares `'timers'` as a core dependency
and passes `clock: { now: () => core.timers.clock.now() }` (only
when the user didn't override it themselves).
- `defineActivePerm` does the same for the perm client's decision
cache TTL.
- `defineActiveStorage` does the same for envelope TTL. The
underlying engine gains a real `EngineStorageOptions.clock`
field (resolved to `Date.now` when omitted) and threads it
through `entry-runtime.ts`'s `encodeEnvelope` /
`decodeEnvelope` calls. New regression test pins the behaviour:
two engines on the same adapter with different clocks see TTL
through their own clock.
Format / rates: `createRates({ now })` was already injectable;
the format engine itself doesn't read `Date.now` anywhere. The
audit's note about format/rates clock injection was about user
documentation, not factory wiring.
Suite: 1512 / 1512 (+1 storage clock test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
f82e174708 |
Bloque C3 — `applySessionAutoRefresh` preset wires App.Timers
Audit P2: `withAutoRefresh` already accepted `timers`, `now` and
`random` injectors, but `defineActiveSession` could not wire them
because the auto-refresh wrapper is opt-in (the caller decides
when to start the ticker). Result: apps that built sessions
through `App` still fell back to `setInterval` + `Date.now` when
they enabled auto-refresh by hand.
New preset `$active-app/presets/session-auto-refresh.ts` closes
the loop:
- `applySessionAutoRefresh(App, opts?)` calls
`withAutoRefresh(App.session, { ...opts, timers: App.Timers,
now: () => App.Timers.clock.now() })`.
- Caller-provided `timers` / `now` / `random` still win.
- Returns the same idempotent cleanup `withAutoRefresh` returns.
Re-exports through `$active-app/presets`. Two regression tests
verify the preset routes through the App's clock and lets the
caller override `random` when jitter is enabled.
Other determinism in `arts/session` (engine clock for
`expiresAt`, broadcast channel) was already injectable; the
preset is the missing wiring piece for the App composition path.
Suite: 1512 / 1512.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
1a92d280dc |
Bloque C1+C2 — http retry/timeout determinism via `HttpTimerPort`
Audit P2: `http/retry.ts` and `http/timeout.ts` reached for
`Date.now`, `Math.random`, host `setTimeout` and `clearTimeout`
directly, breaking determinism in tests / replay and routing
around the ecosystem's "all time via timr" rule when used inside
the App composition.
New `HttpTimerPort` interface bundles `now`, `random`, `setTimeout`
and `clearTimeout`. Defaults route to host primitives via
`createDefaultHttpTimerPort()`. `EngineHttpOptions` exposes the
four functions individually so callers can replace any subset; the
engine bundles them into an internal `port` field on
`ResolvedHttpDefaults` and threads it through:
- `computeRetryDelay(policy, attempt, response, port)` — `now()`
drives `Retry-After` math, `random()` drives jitter.
- `delayWithSignal(ms, signal, port)` — schedules + cancels via the
port's `setTimeout` / `clearTimeout`.
- `attemptTimeoutSignal(ms, port)` and `totalTimeoutSignal(ms, port)`
now return `{ signal, cancel }` instead of a bare `AbortSignal`.
The engine calls `cancel()` when each attempt settles and when
the request finishes, closing the audit's "leaked timeouts in
long-volume runtimes" finding.
- `defineEngineHttp` wires `now: () => core.timers.clock.now()`
from `App.Timers`, so the App path uses a single clock; `random`
and `setTimeout` keep host defaults (deterministic injection
remains an opt-in per call).
`mergeHttpOptions` propagates the port fields too, so
`engine.with({...})` keeps test injectors intact.
Tests: timeout suite gains 4 cases (cancel suppresses fire,
injected port is honored, both attempt and total scopes); retry
suite migrates to a `PORT = { now, random }` constant. Original
behavior unchanged.
Suite: 1510 / 1510.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
a52f918be2 |
Bloque B3 — connection WebSocket test coverage
Audit P2: `websocket.test.ts` only validated the "WebSocket missing" error path. The transport's actual surface is substantial — URL/protocols factory resolution, binaryType forcing, browser open/message/close/error mapping, state projection, send/bufferedAmount, close forwarding, and listener cleanup between sockets — and all of it shipped untested. Adds a full coverage suite using a hand-rolled mock WebSocket constructor (captures URL/protocols, lets the test drive open/message/close/error transitions deterministically). 11 new cases: - URL + protocols factories invoked at open() time - `binaryType` forced to `arraybuffer` on every fresh socket - open() resolves on browser open + state flips to OPEN - string and ArrayBuffer messages forwarded to onMessage - open() rejects on close-before-open (with the close meta) - open() rejects on error-before-open - post-open errors hit onError without re-settling open() - send() forwards both string and ArrayBuffer; bufferedAmount reads through to the socket - close() forwards code+reason and transitions to CLOSED - listener cleanup verified across reconnect (no leaks from the previous socket fire on the next one) - transport.kind === 'websocket' The original "WebSocket unavailable" test stays. Suite: 1507 / 1507. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
45011ea4a7 |
Bloque B2 — auth handlers route DEVICES + DEVICE_REVOKE
Audit P1: `ActiveAuth.listDevices()` and `revokeDevice()` POSTed
to `/api/auth/devices` and `/api/auth/devices/revoke`, but the
generic handler (the same one used by the SvelteKit integration)
only routed current/csrf/password/recovery/sign-out — devices and
OAuth fell through to 404.
Closes the device gap end-to-end:
- `AuthHandlerEngine` (handler-runtime contract) now declares
`listDevices` and `revokeDevice`. The engine already implemented
them; the gap was purely in the handler surface.
- `createAuthRouteHandlers` adds two new handlers and registers
them in `handle()`:
- `GET /api/auth/devices` → `engine.listDevices({ actorRef })`
- `POST /api/auth/devices/revoke` → CSRF-verified, body
`{ deviceId, meta? }` → `engine.revokeDevice({ actorRef, ... })`
- Both derive `actorRef` via a new internal `requireAuthCurrent`
helper that calls `engine.current()` and returns 401 when the
session is anonymous, mirroring how the rest of the auth API
treats unauthenticated requests.
OAuth / MFA / WebAuthn endpoints (which the audit also flagged in
the same finding) stay deferred — those are bigger surface
additions that need server-side flow work, not just routing. The
client cooperates: those methods are not yet declared on
`ActiveAuth`. Devices / device revoke are the only pair the client
already exposed and the handler ignored.
Test: `src/svrs/auth/test/handlers-devices.test.ts` exercises
routing + auth gating with stub engines (4 cases). Engine-level
device semantics (revocation invalidates bound sessions etc.)
remain covered by `engine-password.test.ts`.
Suite: 1496 / 1496.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
2a6706370e |
Bloque B1 — perm SQL compiler resolves nested paths via getPath
Audit P1: the SQL compiler used a literal property lookup (`(input.actor as Record<string, unknown>)[expr.path]`) for actor and context references, while the in-memory runtime evaluator goes through `getPath()` which respects the `.` separator. A policy condition like `actor.risk.mfa === true` therefore resolved correctly in memory but produced `undefined` in the SQL parameter — silently misaligning DB-side filters with allow/deny decisions. Fix: route both `PERM_ROOT_ACTOR` and `PERM_ROOT_CONTEXT` through `getPath()` in `src/libs/perm/compilers/sql.ts` so both code paths agree on segmentation. Resource references stay on `columnName` (they map to a real DB column, not to a JS object). Adds `src/libs/perm/test/sql-nested-paths.test.ts` with three regression cases: 1. Nested actor path (`actor.risk.mfa`) emits the resolved value. 2. Nested context path (`context.request.region`) likewise. 3. Missing nested path emits `undefined`, matching the runtime evaluator (so the SQL/runtime alignment doesn't accidentally diverge in the "missing" case either). Caveat documented in the new comment: the fix assumes DB column names don't contain `.`. Apps that need columns with dotted identifiers must override `columnName` and the actor/context paths must avoid `.` for those references. Suite: 1492 / 1492. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
5efec94367 |
Bloque D — uniform lifecycle for declarable services
Audit P2: every service the app declares in `createActiveApp({
services })` should respect the same dispose contract: idempotent,
post-dispose mutators are inert, no late side effects on torn-down
subscriptions.
storage:
- New `STORAGE_ERR_DISPOSED` + `StorageDisposedError` (with
`isStorageDisposedError` guard).
- `entry()`, `clear()` and `entries()` throw `StorageDisposedError`
after `dispose()` instead of silently mutating refcounted
registries with the bus already torn down.
- Re-exports added to the index barrel.
frontend:
- `ActiveFrontend.disposed` getter on the public type.
- Every mutating setter (`setLocale`, `setDir`, `clearDir`,
`setTheme`, `setMode`, `clearMode`, `setReducedMotion`,
`clearReducedMotion`, `setReducedSound`, `setDensity`) now
short-circuits when disposed, so a late media-query event or a
locale-source emit during teardown can't rewrite the DOM through
a torn-down `applyDom()`. Read-only getters keep returning the
last applied value.
- `onPreferenceChange` returns a no-op detacher post-dispose.
- `dispose()` is idempotent (was already, now also guarded against
resurrected mutations).
format:
- `ActiveFormat.disposed` getter on the public type.
- `dispose()` is now idempotent at the root and walks each
sub-engine in stable order.
- `setLocale()` is a no-op post-dispose.
Tests: +3 regression tests (one per art) covering the new dispose
semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
f1055842dd |
Bloque A — sweep stale aliases and `App.<Capitalized>` references
Audit P1: documentation must stop teaching APIs the runtime no longer exposes. The svelte.config.js aliases are full words now (`$cache`, `$session`, `$connection`, `$timer`, `$logger`, `$format`, `$storage`, `$active-app`, `$bus`); the legacy 4-letter forms (`$cach`, `$sess`, `$conn`, `$timr`, `$logr`, `$fmts`, `$stor`, `$aapp`, `$buss`) were retired earlier but still lived in READMEs, demo pages, comments and a few code docstrings. Likewise, the `App.<service>` surface is lowercase for declarable services. The capitalized form is reserved for the four-piece core (`Logger`, `Bus`, `Timers`, `Orca`). References like `App.Cache`, `App.Sess`, `App.Storage`, `App.Format`, `App.Frontend`, `App.Lang`, `App.Auth`, `App.Perms`, `App.Http`, `App.Dom`, `App.Sium` were either ported to the new lowercase or migrated where it made sense. Mechanical sweep across `src/`, then a guard script: - `scripts/check-aliases.mjs` walks `src/`, fails the run if any forbidden alias or `App.<forbidden capitalized>` appears in any `.ts` / `.svelte` / `.md` / `.txt` / `.js` / `.mjs` file. `arts/active-app/types.ts` is allowlisted because its block comment explicitly documents the legacy uppercase surface as "removed". - `npm run test:aliases` exposes the script. - `npm run test:all` now includes the alias check. No runtime change; tests still 1486 / 1486. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
e6b6074c94 |
Address Codex audit P2.11 — split orca pure helpers out of `engine-orca.ts`
The engine file shrank from ~1900 LOC to ~1430 by lifting the state-free helpers into three focused modules: - `ids.ts` — `createDefaultIdFactory(timers)`. Already conceptually factored after P1.4; this commit moves the implementation out so the engine no longer references `TimerScheduler` from a one-off factory. - `validation.ts` — the entire `Orca.validate()` analysis: `validateConfiguration` orchestrator, `sortActionsCanonical`, `validateGatesForEvent`, `validateCyclesForEvent`, `validateTransactionsForEvent`, `canonicalCycleFingerprint`, and the public `RegisteredActionEntry` shape they share. Pure functions over the registry map; safe to unit-test in isolation. - `runner-helpers.ts` — `buildWaves`, `evaluateGates`, `interruptedActionRun`, `mapResultToActionStatus`, `computeRunStatus`. `computeRunStatus` now takes plain primitives (`traceAborted`, `traceAbortedReason`) instead of the engine's internal `TraceState` map, so the helper module has zero knowledge of engine state. `engine-orca.ts` keeps only the genuinely stateful orchestration core: registry, queue, drain / spawn / executeRun, runAction / runActionWithTimeout / runCompensation, ALS bus interception, and the public API surface. Constants and types that became private to the extracted modules left the engine's import block too — the file is now scannable in a single pass without having to swap mental contexts between "validation rules" and "run loop". No behavioural change. Suite: 1486 / 1486. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
c1d1ae9534 |
Address Codex audit P3.15 — ecosystem orca integration test
New `src/arts/active-app/test/ecosystem-orca.test.ts` validates the
canonical motivating scenario for `arts/orca`:
- user A → user B switch fires cache.clear, perm.invalidate and
connections.reauthenticateAll in a single orca trace, with one
runId and the union of every preset's `provides` tokens.
- session revoke fires cache.clear-on-revoke and
connections.closeAll('session-revoked'); identity-change actions
do not run on revoke.
- when one art's reaction throws, the others still run because
every preset declares `onError: continue`; the failing action is
recorded in the run trace with status `error`, run status
`partial`.
- the detacher returned by `applyStandardOrca` unregisters every
preset — subsequent events are inert.
Closes the audit's "missing compound test of the user A → user B
scenario" finding and replaces the README's pending-note with a
pointer to the live test file.
Suite: 1486 / 1486 (+4 from this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
2473ade4ad |
Address Codex audit P1.6 — connection presets + dead `autoReauthOn` removal
Two new orca presets in `arts/active-app/presets/`:
- `applyConnectionsReauthOnIdentityChange` — listens to
`SESSION_EVENT_IDENTITY_CHANGED` and calls
`App.connections.reauthenticateAll()`. Closes the canonical motivating
scenario for orca: "chat connected with the previous user's
credentials" can no longer happen with this preset wired.
- `applyConnectionsCloseOnRevoke` — listens to `SESSION_EVENT_REVOKED`
and calls `App.connections.closeAll('session-revoked')`, leaving no
socket alive carrying revoked credentials.
`applyStandardOrca` now picks both up automatically when `App.connections`
is declared, and the index barrel re-exports the new shapes.
Removes the dead `autoReauthOn` config — declared on
`EngineConnectionsOptions` but never read by any runtime code:
- field removed from `connection/types.ts`
- `CONNECTION_AUTO_REAUTH_*` constants removed from `connection/consts.ts`
- `ConnectionAutoReauthOn` / `ConnectionAutoReauthTarget` types removed
- unused test import removed from `connection.test.ts`
- connection README rewritten: orca preset is now the canonical bridge,
per-connection `session: { ... }` documented as the manual / standalone
alternative
- demo route artifact-docs.ts and aapp page updated to use
`applyStandardOrca(App)` instead of `autoReauthOn: 'standard'`
The per-connection `session-wiring.ts` mechanism stays as-is — it's
useful for connections that live outside an App composition or that
need a custom `ConnectionSessionSource`. README now spells out the
two paths: orca preset for App-composed apps, per-connection `session`
for manual control.
Suite: 1482 / 1482 (+4 from this commit: 3 preset behaviour tests
+ 1 `applyStandardOrca` connection wiring test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
c0ed619372 |
Address Codex audit P2.7 / P2.8 / P2.9 / P2.10
P2.7 — `ctx.throwIfAborted()` helper. The action context now exposes `throwIfAborted()`. It throws an `AbortError`-style exception when `signal.aborted` is true, idiomatic for breaking out between awaits and avoiding side effects after a timeout / run-abort / dispose. Compensation contexts get the same helper. Documents cooperative cancellation as a hard contract: actions that touch external state must check `signal.aborted` (or call this helper) before mutating, especially after long awaits. Three regression tests (no-op when live, throws inside FINALLY when upstream aborted, prevents post-timeout side effects). P2.8 — global serial lane decision documented. Reformulates `canStartRun` with a doc block making the design explicit: non-parallel events share a single global lane (at most one fifo / replace-queued / drop-latest run in flight at any time); parallel-policy events bypass the lane. README's queue-policies section now leads with the lane invariant and the v2 roadmap lists "per-event concurrency lane for non-parallel policies" as a deferred upgrade. P2.9 — `commit()` documented as production seal, not a mandatory step. Adds a "When to commit" paragraph to the JSDoc: apps with an eager bootstrap should commit; apps that register actions from lazy-loaded routes must not. Removes the implicit assumption that every app should call commit during init. P2.10 — bus interception documented as bonus diagnostic, not a contract. README's bus-interception bullet now warns that the ALS attribution is an opt-in-by-environment feature: it improves the trace where AsyncLocalStorage exists (Node/Bun, modern browsers with AsyncContext) and silently degrades to root-event semantics elsewhere. Hard rule: actions must use `ctx.emit()` for attribution-critical fan-out; reserve `bus.publish` for genuinely root events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
7e2907a75b |
Address Codex audit P1.1 / P1.2 / P1.3 / P1.4 + leftover cleanup
P1.1 — trace cleanup safe under parallel runs. `TraceState` gains `inFlightRuns`. `spawnRun` increments it synchronously before the IIFE awaits `executeRun`, decrements it in its `finally` and only then attempts `maybeReleaseTrace`. The release helper waits for both the queue snapshot and `inFlightRuns` to be empty before dropping the entry. Previously a parallel sibling finishing first could erase counters another run still relied on (reentry guards, dedupeKeys, abort flags). P1.2 — `provides` becomes the actual contract. When an action declares a non-empty `provides`, `runAction` checks each emitted token against it and emits `orca.configuration.invalid` for every undeclared token. Soft enforcement: the token is *not* dropped, keeping runtime back-compat; the diagnostic flags drift between the declaration and the runtime so authors notice. A v2 strict-drop mode can opt in later. P1.3 — `replace` queue policy renamed. The constant is now `ORCA_QUEUE_REPLACE_QUEUED` (literal `'replace-queued'`). The old name implied `takeLatest`-style "abort in-flight + queue new", which the engine never did. The hard variant lives in Roadmap v2 as `'replace-current'`. Tests updated; v1 has no external consumers yet so no back-compat alias. P1.4 — `idFactory` becomes injectable. New `OrcaIdFactory` type + `EngineOrcaOptions.idFactory`. Default factory uses the injected `timers.clock.now()` (no more direct `Date.now()` violating the "all time via timr" rule); replay/snapshot tests pass a deterministic counter. Eliminated `generateRunId` / `generateEventId` / `generateTraceId` standalone helpers. Cleanup leftovers from the audit: - `engine-orca.ts` header rewritten — was still claiming `after`/`unless`/`abortOn`/`actionTimeoutMs`/`compensate` are "accepted, ignored". Now describes the real surface. - `README.md` "Estado Del Documento" already updated; this commit also drops the legacy `## Roadmap` block, removes the `setupOrca` recommendation (moved to roadmap), rewrites "Tokens Flag" to cover the with-payload form, refreshes the Diagnostics list to match `consts.ts`, and replaces the "Tests Requeridos" wishlist with a snapshot of actual coverage + the pending ecosystem test. Tests: +5 (149 in engine-orca.test.ts, 16 in active-orca, 0 in result.test.ts → 165 in orca; 1475 / 1475 across the repo). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
38462f3282 |
Cleanup: remove stale orca v0/v0.1+ markers across docs and types
After v1 closed, the public surface still carried `@v0.0 Accepted, ignored`, `@v0.1+ never produced`, `@v1+ never invoked` notes that no longer match the engine. They lied to readers about what the runtime does. Code: - `types.ts` — rewrite docstrings for `OrcaTimeout`, `OrcaFatal`, `ctx.tokens`, `after`, `unless`, `abortOn`, `provides`, `actionTimeoutMs`, `onError`, `compensate` to describe current behaviour. - `consts.ts` — `ORCA_RESULT_TIMEOUT` / `_FATAL` and the error policy block lose their "never produced" / "v0.0" hedges. - `result.ts` — `orcaTimeout` / `orcaFatal` get real docstrings instead of `@v0.1+` markers. Docs: - `orca/README.md` "Estado Del Documento" — summarise v1 surface (engine + active wrapper, queue policies, transactions, fan-in, bus interception, tokens with payload). Drop the legacy `## Roadmap` section that listed v0/v0.1/v1 line items already delivered or already covered by the lower "Roadmap v1" / "v2" sections. - `active-app/README.md` — drop the dangling "orca v0.0" link. - `docs/orca_minds.txt` — prepend an ARCHIVED banner. - `docs/active-app-refactorizacion.md` — replace the "Working document, abierto a evaluación" header with an ARCHIVED notice (refactor completed 2026-05-04). No behavioural change. Tests still 1471/1471. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
6217c86b0d |
createActiveOrca — Svelte 5 reactive wrapper, closes orca v1
`createActiveOrca(options)` returns the engine's full interface plus five `$state`-backed snapshot properties: `runningSnapshot`, `recentRunsSnapshot`, `latestRun`, `committedSnapshot`, `disposedSnapshot`. The cells are refreshed inside an `engine.onChange` listener — no `$effect` chains, so no risk of `effect_update_depth_exceeded`. To wire that, `EngineOrca` gains `onChange(listener): () => void` and notifies on register / detach / run-start / run-complete / commit / dispose. Run controllers now live on `spawnRun` (created synchronously and tracked in `inFlightControllers`) instead of inside `executeRun`, so the `running` getter flips before the first await — reactive consumers see the start tick. Also stabilises an existing parallel-waves test that flaked when the suite ran under heavier concurrent load by raising the await margin. With this change, every line of the v1 roadmap is honoured by the engine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
23351e99c2 |
Bus interception — attribute `bus.publish` from inside an action
When a module calls `bus.publish('e', payload)` from the body of a
running orca action, the engine now treats the resulting bus event
as a child of that action: same `traceId`, `parentEventId` pointing
at the active run's envelope, depth+1, `emittedByAction` set to the
action's id. Previously the event entered as a fresh root and broke
causal traceability.
Implementation: a new `als.ts` module loads `AsyncLocalStorage`
cross-env — sync detect on `globalThis` first, fallback to dynamic
`node:async_hooks` import for Node/Bun. The first
`runActionInWave` awaits the loader and caches the resolved value
synchronously; the bus listener reads `getStore()` sync. In
browsers without AsyncContext the cached value stays `null` and
the semantics fall back to root-event (authors there should use
`ctx.emit()` for attribution).
Reentry guards count intercepted events identically to
`ctx.emit()`-derived ones, so depth/event/dedupe limits still
apply. Parallel siblings receive independent ALS contexts via
the standard ALS isolation, so attribution doesn't cross between
in-flight peers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
ef73979899 |
README: document v2 roadmap, move bus-interception to v2
Splits pending work into v1 (only `createActiveOrca()` left), v2 deferred-from-v1 items (bus interception via AsyncLocalStorage, `replace` with abort-in-flight, cross-event and nested transactions), the original v0/v1 roadmap not yet attacked (retry policies, concurrency limits, typed token payloads, graph visualisation, inspector, app presets, integration tests with `sess` / `perm` / `cach` / etc.), and the further-out `active-server` direction. Documenting the deferred set keeps audit context honest about what v1 leaves unfinished without renegotiating each item. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
c9ff825d13 |
Per-event queue policies — fifo / replace / drop-latest / parallel
`orca.configureEvent(event, { queuePolicy })` selects how concurrent
or queued runs of an event are handled:
- `fifo` (default): runs serialise globally with all other
non-parallel runs, preserving the v0 single-queue invariant.
- `replace`: a new event of the same name displaces any queued
envelope (in-flight is not aborted) and emits
`orca.queue.dropped` with `reason: 'replaced'`.
- `drop-latest`: an incoming event is dropped when one of the same
name is already in flight or queued (`reason: 'drop-latest'`).
- `parallel`: runs of this event launch concurrently via spawned
IIFEs and bypass the global non-parallel lock — they can race
with each other and with non-parallel events.
Engine refactor: drainQueue is now sync, walks the queue and
delegates to `spawnRun` (fire-and-forget IIFE). Each run tracks
its `AbortController` in `inFlightControllers` so `dispose()`
aborts them all in one pass. `running` getter reads
`inFlightControllers.size > 0`.
`configureEvent` is idempotent with the same policy, throws on a
conflicting reconfiguration, and throws `OrcaFrozenError` after
`commit()`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
6eb97abe2b |
Honor `fanIn` — quorum gate over multiple tokens
A `fanIn: { tokens, min }` declaration runs the action only when at
least `min` of the listed tokens are present in the wave snapshot.
Default `min = tokens.length` (AND); `min: 1` yields
"first-to-finish wins"; intermediate values give k-of-n quorum
patterns useful for voting / multi-source aggregation.
Evaluation order: `unless` → `abortOn` → `fanIn` → `after`.
`fanIn` and `after` may co-exist; both must pass. Skipped runs
carry `reason: fan-in-not-met:<count>/<min>:<tokens-present>`.
`validate()` reports `unsatisfiable-fan-in` (error) when fewer than
`min` upstream actions on the same event provide any of the listed
tokens — the gate could never fire at runtime. Stabilises an
existing parallel-wave timing test that flaked on slow CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
7341b9c62a |
Tokens with payload — `emits` accepts `{ token, payload }`
Token emissions can now carry arbitrary payload data. The `emits`
array accepts either a bare token name (existing form) or
`{ token, payload }`; both forms mix freely. Downstream actions
read payloads via `ctx.tokenPayloads.get(name)`, with
`ctx.tokens.has()` still answering name-presence. The two views
can diverge: a string-form emission has presence but no payload.
Gates (`after` / `unless` / `abortOn` / `provides`) keep comparing
names only — payload semantics are opt-in metadata. Wave snapshots
extend to payloads, so parallel siblings never read each other's
payloads mid-flight. Last-write-wins on duplicate names.
Surface: `OrcaTokenWithPayload`, `OrcaTokenEmit`,
`OrcaActionContext.tokenPayloads`, `OrcaActionRun.emittedPayloads?`,
`OrcaRunResult.tokenPayloads`. Compensation contexts also expose
`tokenPayloads` so rollback paths can read the run-level state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
6f9b558238 |
Honor `transaction` — atomic groups with immediate LIFO rollback
Actions on the same event sharing a `transaction` tag form an atomic group. When any member completes with `ERROR` or `FATAL`, the engine immediately compensates that group's already-succeeded members in LIFO order of completion, marks the run aborted, and emits `RUN_ABORTED`. Transaction semantics override the failing member's own `onError` — `abort-run` is implicit. Compensators run at most once per action: tx-driven rollback marks its entries as compensated, and the standard pre-`FINALLY` rollback skips them. Non-tx compensable actions still compensate at the global phase. `OrcaActionRun.transactionId` mirrors the tag for trace navigation. `validate()` warns `transaction-without-compensate` when no member of a transaction declares a compensator (rollback would be a no-op). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
a518dcac6b |
Honor `parallel: true` — wave-based execution within a stage
Consecutive `parallel: true` actions in the same stage form a wave that runs concurrently via `Promise.all`. Sequential actions break the wave (each is a wave of one). Tokens emitted inside a wave merge into the run-level set only **after** the wave settles, so parallel siblings never see each other's tokens — gate evaluation runs against a wave-start snapshot. Errors and `OrcaFatal` decide aborts after the surrounding wave settles; in-flight siblings are not cancelled. Compensable parallel successes enter the LIFO stack in registration order. `validate()` retains `provides` until wave end and now flags `unsatisfiable-after` when two parallel siblings cross-depend. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
f62020e687 |
Add Orca.commit() — freeze the action graph
`commit()` flips a one-way `committed` flag; subsequent `onEvent()` calls throw `OrcaFrozenError` (code `ORCA_ERR_FROZEN`). It is idempotent, does not run `validate()` implicitly, and does not disturb already-registered actions, in-flight runs, or detach functions returned before the freeze. `dispose()` keeps precedence over the frozen guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
c996c90dc8 |
Honor compensate: invoke compensators LIFO when a run aborts
Fourth batch of v1 features. With this, OrcaAction.compensate moves
from accepted-and-ignored to a real Saga-style rollback hook: when a
run aborts (OrcaFatal, ORCA_ON_ERROR_ABORT_RUN, or trace-aborted), the
engine walks back through every action that previously completed in
success and invokes its compensator before the FINALLY stage runs.
Engine semantics:
- During the action loop, when an action returns success and has
declared `compensate`, push { action, payload } onto a LIFO stack
(compensable[]). Stage FINALLY actions are not compensable —
FINALLY is the cleanup pass itself.
- On entry to the FINALLY stage with aborted=true and
compensable.length > 0, walk the stack in reverse. Each
compensator gets a fresh AbortController (the run controller is
already aborted) and an OrcaActionContext whose emit() returns
null — compensations do not fan out new events, they roll back.
- Compensations are best-effort: a thrown compensator is recorded as
ORCA_ACTION_STATUS_ERROR but the next compensation in the chain
still runs. v1 chooses best-effort over fail-fast because rolling
back N-1 entries when one of them failed is more useful than
rolling back zero.
- FINALLY actions run AFTER compensations, in normal stage order.
Conceptual order on abort:
action loop → compensation phase → FINALLY → run trace recorded.
- Compensations appear in OrcaRunResult.compensations[], a separate
field from actions[]. The original action's record stays in
actions[] with its original success status; the compensator's
record lives only in compensations[]. This keeps the run trace
accurate (the action did succeed; it was just compensated later).
- Diagnostics: orca.compensation.started (DEBUG),
orca.compensation.completed (DEBUG), orca.compensation.failed
(ERROR). All carry runId, actionId, eventId, traceId, depth.
- Compensation does NOT trigger for PARTIAL runs (CONTINUE-onError
actions that errored without aborting) or TIMEOUT-only runs that
didn't abort. The semantic is "all-or-nothing rollback for
aborted runs", not "partial cleanup".
OrcaRunResult gains a `compensations: readonly OrcaActionRun[]` field
(empty array when no compensation ran).
Tests (+10 in a new "v1 — compensate" describe block):
- does not invoke compensate when the run completes successfully
- invokes compensate of a previously successful action when the run
aborts (the simplest happy path)
- does not invoke compensate of an action that errored itself
- runs compensations in LIFO order (with three compensators)
- runs compensations even when OrcaFatal aborts the run
- continues with remaining compensations even if one throws
(best-effort, the failing compensator's status is ERROR but
earlier and later ones still ran)
- runs FINALLY actions after compensations (order: ['compensate',
'finally'])
- does not invoke compensate when a CONTINUE-onError action errors
(PARTIAL run, no abort)
- emits orca.compensation.{started,completed,failed} diagnostics
- passes the original event payload to compensate
- emits inside ctx during compensation are dropped (return null)
The legacy "accepts compensate without invoking it" test from the v0
forward-compat block was deleted; v0 promise is now v1 reality. The
case it asserted (compensate of a failing action is not invoked) is
now covered explicitly by the new "does not invoke compensate of an
action that errored itself" test.
Verification: 1402/1402 vitest tests pass (92 in orca, +10 from this
commit on top of 82 from previous v1 commits).
README updated: compensate moved from "Roadmap v1" to "Ya en el motor
(de v1)". Remaining v1 items: commit() configuration freeze, queue
policies (commit/replace), transaction groups, parallel,
payload-bearing tokens, fan-in, bus interception (Option B), and
createActiveOrca() reactive wrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
7aebe7c673 |
Add Orca.validate() — static graph analysis of registered actions
Third batch of v1 features. With the gates honored, configuration
mistakes (orphan tokens, dependency cycles, ordering bugs) became
silently fatal: the action skips/blocks at runtime with a misleading
"reason" like "after-not-met:foo" without telling the developer that
"foo" is never produced by anything. validate() catches those before
the first event arrives.
API: EngineOrca.validate(): OrcaValidateResult
- ok: false iff any issue has severity 'error'
- issues: array of OrcaValidateIssue { kind, severity, event,
actionId?, token?, cycle?, message }
- Never throws. Read-only over the registered set; safe to call
multiple times during config; engine does not gate runs on result.
Issue kinds:
- unsatisfiable-after (ERROR) — an action's `after: [T]` is not
produced by any earlier action in canonical order. Action would
always skip at runtime.
- orphan-unless / orphan-abort-on (WARN) — gate token has no
upstream producer. May be deliberate (forward-compat / typo
guard); demoted to warning so `ok` stays true.
- dependency-cycle (ERROR) — actions block on each other through
`after` / `provides`. DFS over an action-level adjacency map
(A → set of action ids it depends on); cycles deduped via a
canonical fingerprint (rotated to lexicographically smallest id
first).
Canonical order matters: actions are sorted by (stage, registeredAt)
so the validator's "did any earlier action provide T" matches what
the engine does at runtime. Concrete consequences:
- A producer registered AFTER the consumer in the same stage is
NOT considered upstream — runtime would skip the consumer, and
validate() reports it.
- A producer in a LATER stage (e.g. POST when consumer is MAIN)
is NOT considered upstream either.
Tests (+11):
- empty engine returns { ok: true, issues: [] }
- happy path: provider in earlier stage satisfies consumer
- reports unsatisfiable-after for missing token
- reports unsatisfiable-after when producer registered AFTER consumer
in the same stage (subtle ordering bug)
- reports unsatisfiable-after when producer is in a later stage
- reports orphan-unless as warning (ok stays true)
- reports orphan-abort-on as warning
- detects direct 2-action cycle
- detects indirect 3-action cycle (cycle.length === 4 with closure)
- warnings do not flip ok to false
- issues are isolated per event (cross-event tokens don't satisfy
each other)
Constants exported from $orca:
ORCA_VALIDATE_UNSATISFIABLE_AFTER
ORCA_VALIDATE_ORPHAN_UNLESS
ORCA_VALIDATE_ORPHAN_ABORT_ON
ORCA_VALIDATE_DEPENDENCY_CYCLE
ORCA_VALIDATE_SEVERITY_ERROR
ORCA_VALIDATE_SEVERITY_WARN
Types exported: OrcaValidateIssue, OrcaValidateIssueKind,
OrcaValidateResult, OrcaValidateSeverity.
README updated: validate() moved from "Roadmap v1" into "Ya en el motor
(de v1)". Remaining v1 items: compensate, commit() configuration freeze,
queue policies (commit/replace), transaction groups, parallel,
payload-bearing tokens, fan-in, bus interception (Option B), and
createActiveOrca() reactive wrapper.
Verification: 1392/1392 tests pass (82 in orca, +11 from this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
79d6d1f776 |
Honor after/unless/abortOn gates in the orca engine
Second batch of v1 features. With this, the `provides`/`emits` token
infrastructure that has lived in OrcaAction since v0 finally has
control-flow consequences: tokens emitted by one action gate the
acceptance of subsequent actions in the same run.
Gate semantics:
- `unless: T[]` — declared on actions that should not run again if
a sibling action already produced their precondition (idempotency
guard). When any token in `unless` is present, the action is
SKIPPED with reason `unless-triggered:<token>`.
- `abortOn: T[]` — declared on actions that must halt when an upstream
flagged danger. When any token in `abortOn` is present, the action
is BLOCKED (distinct from skipped) with reason
`abort-on-triggered:<token>`.
- `after: T[]` — declared on actions whose work depends on tokens
emitted by upstream actions. When any required token is missing,
the action is SKIPPED with reason
`after-not-met:<missing1>,<missing2>,…`.
Evaluation order is deliberate: unless first (idempotency), abortOn
second (halt signal), after third (weakest reason to skip). The first
gate that fires short-circuits the action; later gates are not
evaluated. The FINALLY stage bypasses all gates so cleanup work runs
unconditionally.
Engine changes:
- New evaluateGates(action, tokens, now) helper produces a
synthesized OrcaActionRun when a gate fires, or null when the
action should proceed.
- executeRun calls evaluateGates() right after the trace-aborted
short-circuit and before the per-action AbortController is set up.
Gated actions emit either ACTION_SKIPPED or ACTION_BLOCKED
diagnostics and never reach runAction().
- The "run aborted between stages" path now also emits an
ACTION_BLOCKED diagnostic with reason 'run-aborted', so blocked
actions are observable in logs regardless of cause.
- OrcaActionRun.interruptedReason renamed to OrcaActionRun.reason —
the field carries gate, reentry, or authored reasons uniformly
across SKIPPED, BLOCKED, INTERRUPTED. The status determines what
kind of reason it is.
New constants exported from $orca:
- ORCA_GATE_REASON_AFTER_NOT_MET
- ORCA_GATE_REASON_UNLESS_TRIGGERED
- ORCA_GATE_REASON_ABORT_ON_TRIGGERED
- ORCA_GATE_REASON_RUN_ABORTED
- ORCA_DIAGNOSTIC_EVENTS.ACTION_BLOCKED
Tests (+11):
v1 — after gate (3):
- skips an action whose `after` token is missing
- runs the action when every `after` token has been emitted
- reports every missing token in the reason when partially met
v1 — unless gate (2):
- skips an action when any `unless` token is present
- runs the action when no `unless` token is present
v1 — abortOn gate (3):
- blocks an action when an abortOn token is present
- runs the action when no abortOn token is present
- emits orca.action.blocked diagnostic with the abortOn reason
v1 — gate precedence and FINALLY bypass (3):
- unless wins over after when both would short-circuit
- abortOn wins over after when both would short-circuit
- FINALLY stage bypasses gates so cleanup always runs
The legacy "accepts after/unless/abortOn without enforcing" forward-
compat tests from the v0 ignored-fields block are removed; v0 promise
is now v1 reality. The orcaInterrupted-reason test was updated to read
the renamed `reason` field.
Verification: 1381/1381 tests pass (71 in orca, +11 from this commit
on top of 63 from the previous v1 commits).
README updated: gates moved from "Roadmap v1" to "Ya en el motor (de
v1)". Remaining v1 items: compensate, commit/replace queue policies,
transaction groups, parallel, payload-bearing tokens, fan-in, bus
interception (Option B), validate()/commit() static graph validation,
and createActiveOrca() reactive wrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
793767fe0d |
Honor actionTimeoutMs and OrcaFatal in the orca engine
First batch of v1 features. These were accepted in the public surface
since v0 but the engine ignored them — the documents called out
ORCA_RESULT_TIMEOUT as never produced, OrcaFatal as treated like
error. Now both are real.
actionTimeoutMs:
- In runAction, if action.actionTimeoutMs > 0, race the action's
promise against a timer scheduled on the injected TimerScheduler.
When the timer wins, it aborts the action's signal and resolves
with orcaTimeout(timeoutMs). The action's promise keeps running
in the background; the run trace records the timeout regardless.
- Each action gets its own AbortController, chained to the run-level
controller via an addEventListener('abort') hop. A timeout aborts
just that action; sibling actions in the same stage proceed.
- Diagnostic orca.action.timeout fires with timeoutMs and the usual
envelope identity.
- Run status: any TIMEOUT action puts the run in ORCA_RUN_TIMEOUT.
OrcaFatal:
- orcaFatal(error) result now maps to ORCA_ACTION_STATUS_FATAL (was
silently mapped to ERROR).
- In executeRun, FATAL status aborts the run unconditionally — it
overrides the action's onError policy. CONTINUE-flagged actions
that return fatal still abort.
- The FINALLY stage continues to run after a fatal abort.
- Diagnostic orca.action.fatal fires at LogLevel.FATAL with a
fatal: true marker and the error payload.
- Run status: any FATAL action puts the run in ORCA_RUN_FATAL.
Run-status precedence is now explicit:
FATAL > TIMEOUT > ABORTED > INTERRUPTED > PARTIAL > SUCCESS
Tests (+9):
v1 — actionTimeoutMs (5):
- produces ORCA_RESULT_TIMEOUT when action exceeds its timeout
- runs to completion when action finishes before timeout
- aborts the action signal when the timeout fires (cooperative
cancellation observable inside the action)
- emits orca.action.timeout diagnostic with timeoutMs
- lets later actions in the same stage proceed after a timeout
v1 — OrcaFatal (5):
- maps OrcaFatal to ORCA_ACTION_STATUS_FATAL
- aborts the run regardless of onError policy when fatal fires
- still runs FINALLY stage after a fatal abort
- emits orca.action.fatal diagnostic with fatal: true
- precedence: fatal beats every other status
The legacy "accepts actionTimeoutMs without enforcing timeout" test
from the v0 forward-compat block was removed; v0 promise is now v1
reality. Also dropped the equivalent OrcaFatal-as-error compatibility
behavior — fatal is now a distinct status across the engine.
Tests use the real createEngineTimers() in the timeout block so the
fake timer's not-implemented schedule() doesn't interfere; the existing
fake timer continues to serve every other test that doesn't rely on
actual scheduling.
Verification: 1373/1373 tests pass (63 in orca, +9 from this commit on
top of the 54 from the v0-kernel + verification commits).
README updated: actionTimeoutMs and OrcaFatal moved out of "Roadmap v1"
into a "Ya en el motor (de v1)" section. Remaining v1 items: compensate,
after/unless/abortOn gates, commit/replace queue policies, transaction
groups, parallel, payload-bearing tokens, fan-in, bus interception
(Option B), validate()/commit() static graph validation, and
createActiveOrca() reactive wrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
0f52fdfce6 |
Close orca v0 verification gaps left by the previous commit
The previous commit added reentry guards and ctx.emit() but the tests
only covered the most visible behaviors. Honest audit surfaced six
untested paths:
- orcaInterrupted() helper round-trip into ORCA_RUN_INTERRUPTED
- maxEventsPerTrace guard (only maxDepth and repeatedEventLimit had
dedicated tests)
- the emit/blocked/aborted/interrupted diagnostic events with their
enriched meta (eventId/traceId/parentEventId/depth/emittedByAction)
- dispose() while a trace has live work
- the ORCA_RUN_INTERRUPTED status appearing on actual runs (the
previous "abort-trace" test only checked run count)
- run-scoped diagnostics carrying envelope identity consistently
Adds 7 tests across the existing v0 envelope/reentry block plus a new
diagnostics block:
envelope/reentry block (+3):
- action that returns orcaInterrupted maps to interrupted status
and run (validates orcaInterrupted helper end-to-end)
- blocks the (N+1)th event in a trace when N = maxEventsPerTrace
- the existing abort-trace test gained an assertion that the
child run completed before the abort took effect, plus a
follow-up bus publish to verify the trace is sealed.
diagnostics block (+5):
- emits orca.event.emitted carrying traceId, parentEventId, depth
and emittedByAction
- emits orca.reentry.blocked when a guard rejects an emit
- emits orca.trace.aborted when policy is abort-trace
- emits orca.action.interrupted when an action returns
orcaInterrupted
- every run-scoped diagnostic carries eventId/traceId/depth
consistently across run.started / action.started / run.completed
Test infrastructure: introduces createCapturingLogger() that intercepts
the Logger interface and pulls structured DiagnosticEntry rows from
the catalogued log routing in libs/logger/diagnostics.ts. The
input.context.diagnostic shape is documented enough to be a stable
public test interface without reaching into internals.
Total: 1364/1364 vitest tests pass (54 in orca, +7 from this commit on
top of the 10 from the kernel commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
feacd46c62 |
Promote orca v0 from callback runner to orchestration kernel
Implements the v0-kernel design captured in docs/orca_minds.txt: the
engine now carries every event through an internal envelope, exposes
trace identity to actions, and bounds derived-event chains through
configurable reentry guards. The previous engine was effectively a
callback runner with stages; this commit turns it into a runtime that
can answer "where does this event come from, how deep is it, and when
should I stop?" without polluting user-defined payloads with runtime
metadata.
New public surface:
- OrcaEnvelope<TPayload> + OrcaEventMeta (eventId, traceId,
parentEventId, parentRunId, emittedByAction, depth, stack,
publishedAt, dedupeKey)
- OrcaActionContext gains eventId / traceId / parentEventId / depth
plus emit(event, payload, options?) -> OrcaEventId | null
- OrcaResult adds OrcaInterrupted (with orcaInterrupted() helper)
- OrcaActionRun.status and OrcaRunResult.status add 'interrupted'
- OrcaRunResult exposes eventId / traceId / parentEventId / depth
- OrcaReentryOptions on EngineOrcaOptions: maxDepth (16),
maxEventsPerTrace (128), repeatedEventLimit (2),
repeatedEventPolicy (skip / abort-trace / error)
- Constants for reentry policies and reasons; OrcaReentryError class
Engine semantics:
- Bus publishes are roots: fresh traceId, depth=0, no parent. They
never enter the reentry counters.
- ctx.emit() builds a child envelope inheriting the parent's traceId
and incrementing depth. The child is enqueued, never executed
inline.
- Reentry guards apply only to derived envelopes. Crossing maxDepth,
maxEventsPerTrace, repeatedEventLimit, or matching a previous
dedupeKey triggers the configured policy. Skip blocks just that
envelope; abort-trace marks the trace and skips every queued event
that belongs to it; error throws OrcaReentryError synchronously.
- dispose() marks every live trace aborted with reason 'disposed' so
late ctx.emit() calls (e.g. from compensating cleanup) get a clean
rejection instead of an exception.
Diagnostics gain four new event types
(action.interrupted, event.emitted, reentry.blocked, trace.aborted)
and every existing one carries the envelope identifiers
(runId, eventId, traceId, parentEventId, depth) where applicable, so a
log sink can correlate runs without parsing variant tags.
Tests: 10 new tests covering envelope identity (root depth=0, child
depth+1), ctx.emit() trace inheritance, run-trace correlation, orphan
emit, all four reentry guards (maxDepth with disjoint event names so
the same-name limit doesn't interfere, repeatedEventLimit, error
policy throwing OrcaReentryError, abort-trace, dedupeKey), and the
invariant that bus publishes start fresh traces.
Active-app presets keep working unchanged (they don't call ctx.emit
yet); the API extension is additive on the OrcaActionContext side
(presets still type-check against the wider context shape).
Total: 1357/1357 vitest tests pass (47 in orca, +10 from this commit).
Roadmap v1 documented at the foot of the orca README and parked under
@v1+ in the source: actionTimeoutMs runtime, compensate invocation,
after/unless/abortOn gating, OrcaFatal distinction, queue policies
(commit/replace/parallel), transaction/atomic groups, payload-bearing
tokens, fan-in, validate()/commit() static graph validation,
createActiveOrca() reactive wrapper, and the bus.publish interception
(Option B) that would let modules' direct publishes attach
emittedByAction perfectly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |