diff --git a/.changeset/lifecycle-retention-onlywhen.md b/.changeset/lifecycle-retention-onlywhen.md new file mode 100644 index 0000000000..854d443680 --- /dev/null +++ b/.changeset/lifecycle-retention-onlywhen.md @@ -0,0 +1,11 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +'@objectstack/service-automation': minor +--- + +ADR-0057 (#2834): `retention.onlyWhen` status predicate — mixed tables can scope the age reap. + +- **spec**: `lifecycle.retention.onlyWhen` — a row filter (per-field equality or `{ $in: [...] }`) the retention window applies to; rows outside it are retained regardless of age. Rejected when combined with rotation `storage` (shard DROPs ignore filters) or `archive` (the Archiver moves rows by age alone). +- **objectql**: the LifecycleService Reaper merges `onlyWhen` into every retention delete, including tenant-override passes. +- **service-automation**: the run-history age sweep is now declarative — `sys_automation_run` declares `retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }` and the platform Reaper owns it; suspended (`paused`) runs never match. The plugin's own sweep loop is retired: `ObjectStoreSuspendedRunStore.pruneHistory`, the `DEFAULT_RUN_HISTORY_RETENTION_DAYS` export, and the `runHistoryRetentionDays` / `runHistorySweepMs` plugin options are removed (launch-window breaking-as-minor). The write-time per-flow overflow cap (`runHistoryMaxPerFlow`) stays. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 529008d651..1080552db0 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -166,12 +166,19 @@ lifecycle: { retention: { maxAge: '90d' }, archive: { after: '90d', to: 'archive', keep: '7y' }, } + +// Mixed table (live workflow state + terminal history): scope the reap +lifecycle: { + class: 'telemetry', + retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }, +} ``` | Key | Description | | :--- | :--- | | `class` | Persistence contract (see table below); `record` is the implicit default | | `retention.maxAge` | Age-based reap on `created_at` | +| `retention.onlyWhen` | Row filter the reap is scoped to (per-field equality or `{ $in: [...] }`); rows outside it are retained regardless of age. Incompatible with rotation `storage` and `archive` | | `ttl` | Per-row expiry: `field` + `expireAfter` | | `storage` | `{ strategy: 'rotation', shards, unit }` — time-shard + O(1) shard `DROP` (SQLite) | | `archive` | Cold-store hand-off: `after` (must equal `retention.maxAge`) + `to` (datasource name) + optional `keep` | diff --git a/packages/objectql/src/lifecycle/lifecycle-service.test.ts b/packages/objectql/src/lifecycle/lifecycle-service.test.ts index b7a26c79ca..468b5a057e 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.test.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.test.ts @@ -98,6 +98,29 @@ describe('LifecycleService.sweep — Reaper', () => { expect(report.swept[0].policy).toBe('ttl'); }); + it('merges retention.onlyWhen into the reap filter (mixed tables, #2834)', async () => { + const { engine, deletes } = captureEngine([ + { + name: 'sys_automation_run', + lifecycle: { + class: 'telemetry', + retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }, + } as any, + }, + ]); + + const report = await service(engine).sweep(); + + // Age cutoff AND the status predicate — a paused run older than the + // window must never match the delete. + expect(deletes).toHaveLength(1); + expect(deletes[0].where).toEqual({ + created_at: { $lt: isoCutoff('30d') }, + status: { $in: ['completed', 'failed'] }, + }); + expect(report.swept[0].policy).toBe('retention'); + }); + it('never touches record-class or undeclared objects', async () => { const { engine, deletes } = captureEngine([ { name: 'crm_account' }, @@ -498,6 +521,39 @@ describe('LifecycleService.sweep — governance (P4)', () => { expect(deletes).toHaveLength(2); }); + it('retention.onlyWhen survives tenant-scoped overrides on every pass', async () => { + const { engine, deletes } = captureEngine([ + { + name: 'sys_automation_run', + lifecycle: { + class: 'telemetry', + retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }, + } as any, + }, + ]); + (engine as any).find = async (object: string) => + object === 'sys_organization' ? [{ id: 'org_reg' }] : []; + const settings = fakeSettings( + {}, + { org_reg: { retention_overrides: { sys_automation_run: { maxAge: '2y' } } } }, + ); + + await service(engine, { getSettings: () => settings }).sweep(); + + const predicate = { status: { $in: ['completed', 'failed'] } }; + expect(deletes[0].where).toEqual({ + created_at: { $lt: isoCutoff('2y') }, + organization_id: 'org_reg', + ...predicate, + }); + expect(deletes[1].where).toEqual({ + created_at: { $lt: isoCutoff('30d') }, + $or: [{ organization_id: { $nin: ['org_reg'] } }, { organization_id: null }], + ...predicate, + }); + expect(deletes).toHaveLength(2); + }); + it('raises quota and growth alerts (observe-only — no extra deletes)', async () => { const onAlert = vi.fn(); const count = vi.fn(async () => 1_500); diff --git a/packages/objectql/src/lifecycle/lifecycle-service.ts b/packages/objectql/src/lifecycle/lifecycle-service.ts index 2302983f5b..d79d8a6cbf 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.ts @@ -495,7 +495,9 @@ export class LifecycleService { // live shards — and immediately bounds a legacy table the Rotator just // adopted whole into its first shard. const windowMs = this.effectiveWindowMs(ov.maxAge, parseLifecycleDuration(lc.retention.maxAge), object); - outcomes.push(await this.reap(engine, object, lc, 'retention', 'created_at', windowMs, report)); + outcomes.push( + await this.reap(engine, object, lc, 'retention', 'created_at', windowMs, report, lc.retention.onlyWhen), + ); } else if (lc.storage?.strategy === 'rotation' && !rotated && !lc.ttl) { // Rotation declared but the driver can't shard physically: the shard // window IS the bound — enforce the same window with an age-based reap @@ -591,12 +593,16 @@ export class LifecycleService { field: string, windowMs: number, report: LifecycleSweepReport, + onlyWhen?: Record, ): Promise { const cutoff = new Date(this.now() - windowMs).toISOString(); const overrideKey = policy === 'ttl' ? 'expireAfter' : 'maxAge'; const tenantWindows = (this.governance.tenantOverrides.get(object) ?? []).filter( (t) => typeof t[overrideKey] === 'string', ); + // `retention.onlyWhen` narrows every delete to the declared row filter — + // rows outside it (live workflow state) are retained regardless of age. + const scope = onlyWhen ?? {}; let total: number | undefined = 0; const accumulate = (res: unknown) => { @@ -608,7 +614,7 @@ export class LifecycleService { if (tenantWindows.length === 0) { accumulate( await engine.delete(object, { - where: { [field]: { $lt: cutoff } }, + where: { [field]: { $lt: cutoff }, ...scope }, multi: true, context: { ...SYSTEM_CTX }, }), @@ -621,7 +627,7 @@ export class LifecycleService { const tCutoff = new Date(this.now() - tMs).toISOString(); accumulate( await engine.delete(object, { - where: { [field]: { $lt: tCutoff }, organization_id: t.tenantId }, + where: { [field]: { $lt: tCutoff }, organization_id: t.tenantId, ...scope }, multi: true, context: { ...SYSTEM_CTX }, }), @@ -637,6 +643,7 @@ export class LifecycleService { { organization_id: { $nin: tenantWindows.map((t) => t.tenantId) } }, { organization_id: null }, ], + ...scope, }, multi: true, context: { ...SYSTEM_CTX }, diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index dba4c9720a..ffe25c4830 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -59,8 +59,12 @@ import { // ── Audit ───────────────────────────────────────────────────────────────── // sys_audit_log / sys_activity / sys_comment moved to @objectstack/plugin-audit // and sys_presence to @objectstack/service-realtime (ADR-0029 K2 / D8). Their -// i18n extraction now lives in those packages; the already-generated bundles -// here keep working until the next regeneration. sys_attachment stays here +// i18n extraction lives in those packages (each has its own +// scripts/i18n-extract.config.ts + src/translations bundles) and the leftover +// copies here were pruned in the #2834 ⑤ regeneration — verified line-by-line +// against the owning plugins' bundles before committing. The +// bundle-ownership test guards this invariant from here on: this package's +// bundles carry ONLY this package's objects. sys_attachment stays here // pending the storage-domain decomposition (it belongs with service-storage). import { SysNotification, diff --git a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts new file mode 100644 index 0000000000..a920cea12e --- /dev/null +++ b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Bundle-ownership guard (#2834 ⑤ / ADR-0029 D8): this package's generated +// object-translation bundles must carry ONLY objects this package's extract +// config actually imports. When an object moves to another package (audit, +// realtime, …), its translations move to that package's own bundles — a +// leftover copy here silently DIES on the next `os i18n extract` run, taking +// curated translations with it (the sys_audit_log incident). This test turns +// that silent loss into a red build: an object present in the bundle but not +// in the ownership list below means either (a) the extract config gained an +// object — add it here — or (b) a moved object's keys were left behind — +// migrate them to the owning package's bundles, then regenerate. + +import { describe, it, expect } from 'vitest'; +import { enObjects } from './en.objects.generated.js'; + +// Objects the extract config (scripts/i18n-extract.config.ts) imports — +// keep the two lists in sync when adding/moving platform objects. +const OWNED_OBJECTS = new Set([ + // identity + 'sys_user', 'sys_session', 'sys_account', 'sys_verification', 'sys_organization', + 'sys_member', 'sys_invitation', 'sys_team', 'sys_team_member', 'sys_business_unit', + 'sys_business_unit_member', 'sys_api_key', 'sys_two_factor', 'sys_device_code', + 'sys_user_preference', 'sys_oauth_application', 'sys_oauth_access_token', + 'sys_oauth_refresh_token', 'sys_oauth_consent', 'sys_jwks', + // audit / messaging-adjacent (still owned here) + 'sys_notification', 'sys_attachment', 'sys_email', 'sys_email_template', + 'sys_saved_report', 'sys_report_schedule', 'sys_job', 'sys_job_run', 'sys_job_queue', + // metadata + 'sys_metadata', 'sys_metadata_history', 'sys_view_definition', 'sys_metadata_audit', + // system + 'sys_setting', 'sys_secret', 'sys_setting_audit', +]); + +describe('objects translation bundle ownership (ADR-0029 D8)', () => { + it('the en bundle contains no objects owned by other packages', () => { + const strays = Object.keys(enObjects).filter((o) => !OWNED_OBJECTS.has(o)); + expect( + strays, + `bundle carries objects this package's extract config does not own: ${strays.join(', ')} — ` + + 'their curated translations would be silently deleted on the next `os i18n extract`. ' + + 'Migrate them to the owning package (cf. plugin-audit / service-realtime translations) or add them to the extract config + this list.', + ).toEqual([]); + }); + + it('every owned object is present in the bundle (extract config regression)', () => { + const missing = [...OWNED_OBJECTS].filter((o) => !(o in enObjects)); + expect( + missing, + `objects the extract config should emit are missing from the bundle: ${missing.join(', ')} — was an import dropped from scripts/i18n-extract.config.ts?`, + ).toEqual([]); + }); +}); diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index d92df0763c..a7d31b277d 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -42,14 +42,64 @@ export const enObjects: NonNullable = { label: "Ban Expires", help: "When set, the ban auto-clears at this time." }, + failed_login_count: { + label: "Failed Login Count", + help: "Consecutive failed sign-in attempts; reset to 0 on success. Maintained by the auth manager." + }, + locked_until: { + label: "Locked Until", + help: "When set and in the future, sign-in is rejected (brute-force lockout). Auto-clears past this time; an admin can clear it early via Unlock." + }, + password_changed_at: { + label: "Password Changed At", + help: "Timestamp of the last password change. Backs password_expiry_days; system-managed." + }, + phone_number: { + label: "Phone Number", + help: "Sign-in phone number (E.164 recommended). Unique per user; managed by better-auth when the phoneNumber plugin is enabled." + }, + phone_number_verified: { + label: "Phone Verified", + help: "Whether the phone number has been verified (OTP verification requires SMS infrastructure; false until that ships). System-managed." + }, + must_change_password: { + label: "Must Change Password", + help: "When true, the user is blocked (403 PASSWORD_EXPIRED) until they change their password. Stamped by the admin user-management routes; system-managed." + }, + mfa_required_at: { + label: "MFA Required At", + help: "When enforced MFA first applied to this user (grace-period clock). Backs mfa_required; system-managed." + }, + last_login_at: { + label: "Last Login At", + help: "Timestamp of the last successful sign-in. Stamped by the auth manager; system-managed." + }, + last_login_ip: { + label: "Last Login IP", + help: "Client IP of the last successful sign-in (from the trusted proxy forwarded header). System-managed." + }, + ai_access: { + label: "AI Access", + help: "Whether this user holds an AI seat — grants access to the in-UI AI agents (build / ask). The framework synthesizes the `ai_seat` capability from this flag (plugin-hono-server resolveCtx). Assignment is capped by the licensed / purchased seat count (enforced by @objectstack/security-enterprise AiSeatPlugin). Owned by objectql (better-auth is oblivious to this column)." + }, image: { label: "Profile Image" }, manager_id: { - label: "Manager" + label: "Manager", + help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)." }, primary_business_unit_id: { - label: "Primary Business Unit" + label: "Primary Business Unit", + help: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership." + }, + source: { + label: "Identity Source", + help: "How this identity was created — idp_provisioned (federated SSO JIT) or env_native (local signup / app end-user). System-managed; do not edit.", + options: { + idp_provisioned: "IdP-Provisioned", + env_native: "Env-Native" + } }, id: { label: "User ID" @@ -92,6 +142,14 @@ export const enObjects: NonNullable = { label: "Unban User", successMessage: "User unbanned" }, + unlock_user: { + label: "Unlock Account", + successMessage: "Account unlocked" + }, + create_user: { + label: "Create User", + successMessage: "User created" + }, set_user_password: { label: "Set Password", successMessage: "Password updated" @@ -153,6 +211,18 @@ export const enObjects: NonNullable = { expires_at: { label: "Expires At" }, + last_activity_at: { + label: "Last Activity At", + help: "Timestamp of the last request on this session; drives idle-timeout. System-managed." + }, + revoked_at: { + label: "Revoked At", + help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed." + }, + revoke_reason: { + label: "Revoke Reason", + help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)." + }, active_organization_id: { label: "Active Organization" }, @@ -251,6 +321,10 @@ export const enObjects: NonNullable = { password: { label: "Password Hash", help: "Hashed password for email/password provider" + }, + previous_password_hashes: { + label: "Previous Password Hashes", + help: "JSON array of prior password hashes (bounded by password_history_count); reuse-prevention only. System-managed." } }, _views: { @@ -321,6 +395,10 @@ export const enObjects: NonNullable = { label: "Metadata", help: "JSON-serialized organization metadata" }, + require_mfa: { + label: "Require Multi-Factor Auth", + help: "When true, every member of this organization must enroll an authenticator app to access data." + }, id: { label: "Organization ID" }, @@ -645,6 +723,9 @@ export const enObjects: NonNullable = { } }, _views: { + org_chart: { + label: "Org Chart" + }, active: { label: "Active" }, @@ -1226,263 +1307,57 @@ export const enObjects: NonNullable = { } } }, - sys_audit_log: { - label: "Audit Log", - pluralLabel: "Audit Logs", - description: "Immutable audit trail for platform events", - fields: { - created_at: { - label: "Timestamp" - }, - action: { - label: "Action", - help: "Action type (snake_case)", - options: { - create: "create", - update: "update", - delete: "delete", - restore: "restore", - login: "login", - logout: "logout", - permission_change: "permission_change", - config_change: "config_change", - export: "export", - import: "import" - } - }, - user_id: { - label: "Actor", - help: "User who performed the action (null for system actions)" - }, - object_name: { - label: "Object", - help: "Target object (e.g. sys_user, project_task)" - }, - record_id: { - label: "Record ID", - help: "ID of the affected record" - }, - old_value: { - label: "Old Value", - help: "JSON-serialized previous state" - }, - new_value: { - label: "New Value", - help: "JSON-serialized new state" - }, - ip_address: { - label: "IP Address" - }, - user_agent: { - label: "User Agent" - }, - tenant_id: { - label: "Tenant", - help: "Tenant context for multi-tenant isolation" - }, - metadata: { - label: "Metadata", - help: "JSON-serialized additional context" - }, - id: { - label: "Audit Log ID" - } - }, - _views: { - recent: { - label: "Recent" - }, - writes_only: { - label: "Writes" - }, - auth_events: { - label: "Auth" - }, - config_changes: { - label: "Config" - }, - all_events: { - label: "All" - } - } - }, - sys_presence: { - label: "Presence", - pluralLabel: "Presences", - description: "Real-time user presence and activity tracking", + sys_notification: { + label: "Notification", + pluralLabel: "Notifications", + description: "Per-user notification inbox entries", fields: { id: { - label: "Presence ID" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - }, - user_id: { - label: "User" - }, - session_id: { - label: "Session" - }, - status: { - label: "Status", - options: { - online: "Online", - away: "Away", - busy: "Busy", - offline: "Offline" - } + label: "Notification ID" }, - last_seen: { - label: "Last Seen" + topic: { + label: "Topic", + help: "Notification topic, e.g. task.assigned, collab.mention" }, - current_location: { - label: "Current Location" + payload: { + label: "Payload", + help: "Template inputs carried to channels (title/body/url/actor/source/…)" }, - device: { - label: "Device", + severity: { + label: "Severity", + help: "Severity hint for rendering / filtering", options: { - desktop: "Desktop", - mobile: "Mobile", - tablet: "Tablet", - other: "Other" + info: "info", + warning: "warning", + critical: "critical" } }, - custom_status: { - label: "Custom Status" - }, - metadata: { - label: "Metadata", - help: "Arbitrary JSON metadata associated with the presence state (matches PresenceStateSchema.metadata)." - } - } - }, - sys_activity: { - label: "Activity", - pluralLabel: "Activities", - description: "Recent activity stream entries (lightweight, denormalized)", - fields: { - id: { - label: "Activity ID" - }, - timestamp: { - label: "Timestamp" + dedup_key: { + label: "Dedup Key", + help: "Idempotency key within a topic window; a repeat emit is a no-op" }, - type: { - label: "Type", - options: { - created: "created", - updated: "updated", - deleted: "deleted", - commented: "commented", - mentioned: "mentioned", - shared: "shared", - assigned: "assigned", - completed: "completed", - login: "login", - logout: "logout", - system: "system" - } + source_object: { + label: "Source Object", + help: "Object name of the related record (e.g. lead, opportunity)" }, - summary: { - label: "Summary", - help: "Human-readable one-line summary" + source_id: { + label: "Source Record", + help: "Record id within source_object" }, actor_id: { - label: "Actor" - }, - actor_name: { - label: "Actor Name" - }, - actor_avatar_url: { - label: "Actor Avatar" - }, - object_name: { - label: "Object", - help: "Target object short name (e.g. account, sys_user)" - }, - record_id: { - label: "Record ID" - }, - record_label: { - label: "Record Label", - help: "Display label of the target record at write time" - }, - url: { - label: "URL", - help: "Optional deep-link to the activity target" - }, - environment_id: { - label: "Project", - help: "Environment context (multi-environment deployments)" - }, - metadata: { - label: "Metadata", - help: "JSON-serialized additional context" - } - } - }, - sys_comment: { - label: "Comment", - pluralLabel: "Comments", - description: "Threaded comments attached to records via thread_id", - fields: { - id: { - label: "Comment ID" - }, - thread_id: { - label: "Thread", - help: "Thread identifier — conventionally `{object}:{record_id}` (e.g. `sys_user:abc123`)" - }, - parent_id: { - label: "Parent Comment", - help: "Optional parent comment for nested replies" - }, - reply_count: { - label: "Reply Count" - }, - author_id: { - label: "Author" - }, - author_name: { - label: "Author Name" - }, - author_avatar_url: { - label: "Author Avatar" - }, - body: { - label: "Body", - help: "Comment text (Markdown supported)" - }, - mentions: { - label: "Mentions", - help: "JSON array of @mention objects" - }, - reactions: { - label: "Reactions", - help: "JSON array of emoji reaction objects" - }, - is_edited: { - label: "Edited" - }, - edited_at: { - label: "Edited At" - }, - visibility: { - label: "Visibility", - options: { - public: "public", - internal: "internal", - private: "private" - } + label: "Actor", + help: "User who caused the notification (mentioner, assigner)" }, created_at: { label: "Created At" + } + }, + _views: { + recent: { + label: "Recent" }, - updated_at: { - label: "Updated At" + by_topic: { + label: "By Topic" } } }, @@ -1547,60 +1422,6 @@ export const enObjects: NonNullable = { } } }, - sys_notification: { - label: "Notification", - pluralLabel: "Notifications", - description: "Per-user notification inbox entries", - fields: { - id: { - label: "Notification ID" - }, - topic: { - label: "Topic", - help: "Notification topic, e.g. task.assigned, collab.mention" - }, - payload: { - label: "Payload", - help: "Template inputs carried to channels (title/body/url/actor/source/…)" - }, - severity: { - label: "Severity", - help: "Severity hint for rendering / filtering", - options: { - info: "info", - warning: "warning", - critical: "critical" - } - }, - dedup_key: { - label: "Dedup Key", - help: "Idempotency key within a topic window; a repeat emit is a no-op" - }, - source_object: { - label: "Source Object", - help: "Object name of the related record (e.g. lead, opportunity)" - }, - source_id: { - label: "Source Record", - help: "Record id within source_object" - }, - actor_id: { - label: "Actor", - help: "User who caused the notification (mentioner, assigner)" - }, - created_at: { - label: "Created At" - } - }, - _views: { - recent: { - label: "Recent" - }, - by_topic: { - label: "By Topic" - } - } - }, sys_email: { label: "Email", pluralLabel: "Emails", @@ -2505,6 +2326,11 @@ export const enObjects: NonNullable = { label: "Ciphertext", help: "Provider-encoded ciphertext blob (base64 / JSON). Implementation-defined; only the matching ICryptoProvider can read it." } + }, + _views: { + all: { + label: "All Secrets" + } } }, sys_setting_audit: { @@ -2580,6 +2406,11 @@ export const enObjects: NonNullable = { label: "Request ID", help: "Correlates with sys_audit_log / tracing." } + }, + _views: { + recent: { + label: "Recent" + } } } }; diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 949dc63ae8..bf53f97302 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -42,14 +42,64 @@ export const esESObjects: NonNullable = { label: "El bloqueo caduca el", help: "Si se establece, el bloqueo se elimina automáticamente en ese momento." }, + failed_login_count: { + label: "Failed Login Count", + help: "Consecutive failed sign-in attempts; reset to 0 on success. Maintained by the auth manager." + }, + locked_until: { + label: "Locked Until", + help: "When set and in the future, sign-in is rejected (brute-force lockout). Auto-clears past this time; an admin can clear it early via Unlock." + }, + password_changed_at: { + label: "Password Changed At", + help: "Timestamp of the last password change. Backs password_expiry_days; system-managed." + }, + phone_number: { + label: "Phone Number", + help: "Sign-in phone number (E.164 recommended). Unique per user; managed by better-auth when the phoneNumber plugin is enabled." + }, + phone_number_verified: { + label: "Phone Verified", + help: "Whether the phone number has been verified (OTP verification requires SMS infrastructure; false until that ships). System-managed." + }, + must_change_password: { + label: "Must Change Password", + help: "When true, the user is blocked (403 PASSWORD_EXPIRED) until they change their password. Stamped by the admin user-management routes; system-managed." + }, + mfa_required_at: { + label: "MFA Required At", + help: "When enforced MFA first applied to this user (grace-period clock). Backs mfa_required; system-managed." + }, + last_login_at: { + label: "Last Login At", + help: "Timestamp of the last successful sign-in. Stamped by the auth manager; system-managed." + }, + last_login_ip: { + label: "Last Login IP", + help: "Client IP of the last successful sign-in (from the trusted proxy forwarded header). System-managed." + }, + ai_access: { + label: "AI Access", + help: "Whether this user holds an AI seat — grants access to the in-UI AI agents (build / ask). The framework synthesizes the `ai_seat` capability from this flag (plugin-hono-server resolveCtx). Assignment is capped by the licensed / purchased seat count (enforced by @objectstack/security-enterprise AiSeatPlugin). Owned by objectql (better-auth is oblivious to this column)." + }, image: { label: "Imagen de perfil" }, manager_id: { - label: "Gerente" + label: "Gerente", + help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)." }, primary_business_unit_id: { - label: "Unidad de negocio principal" + label: "Unidad de negocio principal", + help: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership." + }, + source: { + label: "Identity Source", + help: "How this identity was created — idp_provisioned (federated SSO JIT) or env_native (local signup / app end-user). System-managed; do not edit.", + options: { + idp_provisioned: "IdP-Provisioned", + env_native: "Env-Native" + } }, id: { label: "ID de usuario" @@ -92,6 +142,14 @@ export const esESObjects: NonNullable = { label: "Desbloquear usuario", successMessage: "Usuario desbloqueado" }, + unlock_user: { + label: "Unlock Account", + successMessage: "Account unlocked" + }, + create_user: { + label: "Create User", + successMessage: "User created" + }, set_user_password: { label: "Establecer contraseña", successMessage: "Contraseña actualizada" @@ -153,6 +211,18 @@ export const esESObjects: NonNullable = { expires_at: { label: "Caduca el" }, + last_activity_at: { + label: "Last Activity At", + help: "Timestamp of the last request on this session; drives idle-timeout. System-managed." + }, + revoked_at: { + label: "Revoked At", + help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed." + }, + revoke_reason: { + label: "Revoke Reason", + help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)." + }, active_organization_id: { label: "Organización activa" }, @@ -251,6 +321,10 @@ export const esESObjects: NonNullable = { password: { label: "Hash de la contraseña", help: "Hash de la contraseña para el proveedor de correo electrónico/contraseña." + }, + previous_password_hashes: { + label: "Previous Password Hashes", + help: "JSON array of prior password hashes (bounded by password_history_count); reuse-prevention only. System-managed." } }, _views: { @@ -321,6 +395,10 @@ export const esESObjects: NonNullable = { label: "Metadatos", help: "Metadatos de la organización serializados en JSON." }, + require_mfa: { + label: "Require Multi-Factor Auth", + help: "When true, every member of this organization must enroll an authenticator app to access data." + }, id: { label: "ID de organización" }, @@ -645,6 +723,9 @@ export const esESObjects: NonNullable = { } }, _views: { + org_chart: { + label: "Org Chart" + }, active: { label: "Activo" }, @@ -1226,263 +1307,57 @@ export const esESObjects: NonNullable = { } } }, - sys_audit_log: { - label: "Registro de auditoría", - pluralLabel: "Registros de auditoría", - description: "Registro de auditoría inmutable para eventos de la plataforma", - fields: { - created_at: { - label: "Marca temporal" - }, - action: { - label: "Acción", - help: "Tipo de acción (snake_case).", - options: { - create: "Crear", - update: "Actualizar", - delete: "Eliminar", - restore: "Restaurar", - login: "Inicio de sesión", - logout: "Cierre de sesión", - permission_change: "Cambio de permisos", - config_change: "Cambio de configuración", - export: "Exportar", - import: "Importar" - } - }, - user_id: { - label: "Actor", - help: "Usuario que realizó la acción (null para acciones del sistema)." - }, - object_name: { - label: "Objeto", - help: "Objeto de destino (p. ej. sys_user, project_task)." - }, - record_id: { - label: "ID de registro", - help: "ID del registro afectado." - }, - old_value: { - label: "Valor anterior", - help: "Estado anterior serializado en JSON." - }, - new_value: { - label: "Valor nuevo", - help: "Estado nuevo serializado en JSON." - }, - ip_address: { - label: "Dirección IP" - }, - user_agent: { - label: "Agente de usuario" - }, - tenant_id: { - label: "Inquilino", - help: "Contexto del tenant para el aislamiento multi-tenant." - }, - metadata: { - label: "Metadatos", - help: "Contexto adicional serializado en JSON." - }, - id: { - label: "ID de registro de auditoría" - } - }, - _views: { - recent: { - label: "Recientes" - }, - writes_only: { - label: "Escrituras" - }, - auth_events: { - label: "Autenticación" - }, - config_changes: { - label: "Configuración" - }, - all_events: { - label: "Todos" - } - } - }, - sys_presence: { - label: "Presencia", - pluralLabel: "Presencias", - description: "Seguimiento en tiempo real de presencia y actividad de usuarios", + sys_notification: { + label: "Notificación", + pluralLabel: "Notificaciones", + description: "Entradas del buzón de notificaciones por usuario", fields: { id: { - label: "ID de presencia" - }, - created_at: { - label: "Creado el" - }, - updated_at: { - label: "Actualizado el" - }, - user_id: { - label: "Usuario" - }, - session_id: { - label: "Sesión" - }, - status: { - label: "Estado", - options: { - online: "En línea", - away: "Ausente", - busy: "Ocupado", - offline: "Desconectado" - } + label: "ID de notificación" }, - last_seen: { - label: "Visto por última vez" + topic: { + label: "Topic", + help: "Notification topic, e.g. task.assigned, collab.mention" }, - current_location: { - label: "Ubicación actual" + payload: { + label: "Payload", + help: "Template inputs carried to channels (title/body/url/actor/source/…)" }, - device: { - label: "Dispositivo", + severity: { + label: "Severity", + help: "Severity hint for rendering / filtering", options: { - desktop: "Escritorio", - mobile: "Móvil", - tablet: "Tableta", - other: "Otro" + info: "info", + warning: "warning", + critical: "critical" } }, - custom_status: { - label: "Estado personalizado" - }, - metadata: { - label: "Metadatos", - help: "Metadatos JSON arbitrarios asociados al estado de presencia (coincide con PresenceStateSchema.metadata)." - } - } - }, - sys_activity: { - label: "Actividad", - pluralLabel: "Actividades", - description: "Entradas recientes del flujo de actividad (ligeras y desnormalizadas).", - fields: { - id: { - label: "ID de actividad" - }, - timestamp: { - label: "Marca temporal" + dedup_key: { + label: "Dedup Key", + help: "Idempotency key within a topic window; a repeat emit is a no-op" }, - type: { - label: "Tipo", - options: { - created: "Creado", - updated: "Actualizado", - deleted: "Eliminado", - commented: "Comentado", - mentioned: "Mencionado", - shared: "Compartido", - assigned: "Asignado", - completed: "Completado", - login: "Inicio de sesión", - logout: "Cierre de sesión", - system: "Sistema" - } + source_object: { + label: "Objeto de origen", + help: "Nombre del objeto del registro relacionado (p. ej. lead, opportunity)." }, - summary: { - label: "Resumen", - help: "Resumen legible de una línea." + source_id: { + label: "Registro de origen", + help: "ID del registro dentro de source_object." }, actor_id: { - label: "Actor" - }, - actor_name: { - label: "Nombre del actor" - }, - actor_avatar_url: { - label: "Avatar del actor" - }, - object_name: { - label: "Objeto", - help: "Nombre corto del objeto de destino (p. ej. account, sys_user)." - }, - record_id: { - label: "ID de registro" - }, - record_label: { - label: "Nombre visible del registro", - help: "Nombre visible del registro de destino en el momento de escritura." - }, - url: { - label: "URL", - help: "Enlace profundo opcional al destino de la actividad." - }, - environment_id: { - label: "Proyecto", - help: "Contexto del proyecto (implementaciones multiproyecto)." - }, - metadata: { - label: "Metadatos", - help: "Contexto adicional serializado en JSON." - } - } - }, - sys_comment: { - label: "Comentario", - pluralLabel: "Comentarios", - description: "Comentarios en hilo adjuntos a registros mediante thread_id.", - fields: { - id: { - label: "ID de comentario" - }, - thread_id: { - label: "Hilo", - help: "Identificador del hilo; por convención `{object}:{record_id}` (p. ej. `sys_user:abc123`)." - }, - parent_id: { - label: "Comentario principal", - help: "Comentario principal opcional para respuestas anidadas." - }, - reply_count: { - label: "Número de respuestas" - }, - author_id: { - label: "Autor" - }, - author_name: { - label: "Nombre del autor" - }, - author_avatar_url: { - label: "Avatar del autor" - }, - body: { - label: "Contenido", - help: "Texto del comentario (compatible con Markdown)." - }, - mentions: { - label: "Menciones", - help: "Matriz JSON de objetos @mention." - }, - reactions: { - label: "Reacciones", - help: "Matriz JSON de objetos de reacción emoji." - }, - is_edited: { - label: "Editado" - }, - edited_at: { - label: "Editado el" - }, - visibility: { - label: "Visibilidad", - options: { - public: "Público", - internal: "Interno", - private: "Privado" - } + label: "Actor", + help: "Usuario que provocó la notificación (quien menciona, quien asigna)." }, created_at: { label: "Creado el" + } + }, + _views: { + recent: { + label: "Recent" }, - updated_at: { - label: "Actualizado el" + by_topic: { + label: "By Topic" } } }, @@ -1547,60 +1422,6 @@ export const esESObjects: NonNullable = { } } }, - sys_notification: { - label: "Notificación", - pluralLabel: "Notificaciones", - description: "Entradas del buzón de notificaciones por usuario", - fields: { - id: { - label: "ID de notificación" - }, - topic: { - label: "Topic", - help: "Notification topic, e.g. task.assigned, collab.mention" - }, - payload: { - label: "Payload", - help: "Template inputs carried to channels (title/body/url/actor/source/…)" - }, - severity: { - label: "Severity", - help: "Severity hint for rendering / filtering", - options: { - info: "info", - warning: "warning", - critical: "critical" - } - }, - dedup_key: { - label: "Dedup Key", - help: "Idempotency key within a topic window; a repeat emit is a no-op" - }, - source_object: { - label: "Objeto de origen", - help: "Nombre del objeto del registro relacionado (p. ej. lead, opportunity)." - }, - source_id: { - label: "Registro de origen", - help: "ID del registro dentro de source_object." - }, - actor_id: { - label: "Actor", - help: "Usuario que provocó la notificación (quien menciona, quien asigna)." - }, - created_at: { - label: "Creado el" - } - }, - _views: { - recent: { - label: "Recent" - }, - by_topic: { - label: "By Topic" - } - } - }, sys_email: { label: "Correo", pluralLabel: "Correos", @@ -2505,6 +2326,11 @@ export const esESObjects: NonNullable = { label: "Texto cifrado", help: "Blob de texto cifrado codificado por el proveedor (base64 / JSON). La implementación lo define; solo el ICryptoProvider correspondiente puede leerlo." } + }, + _views: { + all: { + label: "All Secrets" + } } }, sys_setting_audit: { @@ -2580,6 +2406,11 @@ export const esESObjects: NonNullable = { label: "ID de solicitud", help: "Se correlaciona con sys_audit_log / tracing." } + }, + _views: { + recent: { + label: "Recent" + } } } }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index ae704c8013..052c010adf 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -42,14 +42,64 @@ export const jaJPObjects: NonNullable = { label: "利用停止期限", help: "設定されている場合、この日時に利用停止が自動解除されます。" }, + failed_login_count: { + label: "Failed Login Count", + help: "Consecutive failed sign-in attempts; reset to 0 on success. Maintained by the auth manager." + }, + locked_until: { + label: "Locked Until", + help: "When set and in the future, sign-in is rejected (brute-force lockout). Auto-clears past this time; an admin can clear it early via Unlock." + }, + password_changed_at: { + label: "Password Changed At", + help: "Timestamp of the last password change. Backs password_expiry_days; system-managed." + }, + phone_number: { + label: "Phone Number", + help: "Sign-in phone number (E.164 recommended). Unique per user; managed by better-auth when the phoneNumber plugin is enabled." + }, + phone_number_verified: { + label: "Phone Verified", + help: "Whether the phone number has been verified (OTP verification requires SMS infrastructure; false until that ships). System-managed." + }, + must_change_password: { + label: "Must Change Password", + help: "When true, the user is blocked (403 PASSWORD_EXPIRED) until they change their password. Stamped by the admin user-management routes; system-managed." + }, + mfa_required_at: { + label: "MFA Required At", + help: "When enforced MFA first applied to this user (grace-period clock). Backs mfa_required; system-managed." + }, + last_login_at: { + label: "Last Login At", + help: "Timestamp of the last successful sign-in. Stamped by the auth manager; system-managed." + }, + last_login_ip: { + label: "Last Login IP", + help: "Client IP of the last successful sign-in (from the trusted proxy forwarded header). System-managed." + }, + ai_access: { + label: "AI Access", + help: "Whether this user holds an AI seat — grants access to the in-UI AI agents (build / ask). The framework synthesizes the `ai_seat` capability from this flag (plugin-hono-server resolveCtx). Assignment is capped by the licensed / purchased seat count (enforced by @objectstack/security-enterprise AiSeatPlugin). Owned by objectql (better-auth is oblivious to this column)." + }, image: { label: "プロフィール画像" }, manager_id: { - label: "マネージャー" + label: "マネージャー", + help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)." }, primary_business_unit_id: { - label: "主所属ビジネスユニット" + label: "主所属ビジネスユニット", + help: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership." + }, + source: { + label: "Identity Source", + help: "How this identity was created — idp_provisioned (federated SSO JIT) or env_native (local signup / app end-user). System-managed; do not edit.", + options: { + idp_provisioned: "IdP-Provisioned", + env_native: "Env-Native" + } }, id: { label: "ユーザー ID" @@ -92,6 +142,14 @@ export const jaJPObjects: NonNullable = { label: "利用停止を解除", successMessage: "ユーザーの利用停止を解除しました" }, + unlock_user: { + label: "Unlock Account", + successMessage: "Account unlocked" + }, + create_user: { + label: "Create User", + successMessage: "User created" + }, set_user_password: { label: "パスワードを設定", successMessage: "パスワードを更新しました" @@ -153,6 +211,18 @@ export const jaJPObjects: NonNullable = { expires_at: { label: "有効期限" }, + last_activity_at: { + label: "Last Activity At", + help: "Timestamp of the last request on this session; drives idle-timeout. System-managed." + }, + revoked_at: { + label: "Revoked At", + help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed." + }, + revoke_reason: { + label: "Revoke Reason", + help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)." + }, active_organization_id: { label: "アクティブ組織" }, @@ -251,6 +321,10 @@ export const jaJPObjects: NonNullable = { password: { label: "パスワードハッシュ", help: "メール/パスワードプロバイダー用のハッシュ化パスワード" + }, + previous_password_hashes: { + label: "Previous Password Hashes", + help: "JSON array of prior password hashes (bounded by password_history_count); reuse-prevention only. System-managed." } }, _views: { @@ -321,6 +395,10 @@ export const jaJPObjects: NonNullable = { label: "メタデータ", help: "JSON シリアライズされた組織メタデータ" }, + require_mfa: { + label: "Require Multi-Factor Auth", + help: "When true, every member of this organization must enroll an authenticator app to access data." + }, id: { label: "組織 ID" }, @@ -645,6 +723,9 @@ export const jaJPObjects: NonNullable = { } }, _views: { + org_chart: { + label: "Org Chart" + }, active: { label: "有効" }, @@ -1226,263 +1307,57 @@ export const jaJPObjects: NonNullable = { } } }, - sys_audit_log: { - label: "監査ログ", - pluralLabel: "監査ログ", - description: "プラットフォームイベントの不変の監査証跡", - fields: { - created_at: { - label: "タイムスタンプ" - }, - action: { - label: "アクション", - help: "アクションタイプ(snake_case)", - options: { - create: "作成", - update: "更新", - delete: "削除", - restore: "復元", - login: "ログイン", - logout: "ログアウト", - permission_change: "権限変更", - config_change: "構成変更", - export: "エクスポート", - import: "インポート" - } - }, - user_id: { - label: "操作者", - help: "アクションを実行したユーザー(システム操作の場合は null)" - }, - object_name: { - label: "オブジェクト", - help: "対象オブジェクト(例: sys_user、project_task)" - }, - record_id: { - label: "レコード ID", - help: "影響を受けたレコードの ID" - }, - old_value: { - label: "変更前の値", - help: "JSON シリアライズされた以前の状態" - }, - new_value: { - label: "変更後の値", - help: "JSON シリアライズされた新しい状態" - }, - ip_address: { - label: "IP アドレス" - }, - user_agent: { - label: "ユーザーエージェント" - }, - tenant_id: { - label: "テナント", - help: "マルチテナント分離のためのテナントコンテキスト" - }, - metadata: { - label: "メタデータ", - help: "JSON シリアライズされた追加コンテキスト" - }, - id: { - label: "監査ログ ID" - } - }, - _views: { - recent: { - label: "最近" - }, - writes_only: { - label: "書き込み" - }, - auth_events: { - label: "認証" - }, - config_changes: { - label: "構成変更" - }, - all_events: { - label: "すべて" - } - } - }, - sys_presence: { - label: "在席状況", - pluralLabel: "在席状況", - description: "リアルタイムのユーザー在席状況とアクティビティ追跡", + sys_notification: { + label: "通知", + pluralLabel: "通知", + description: "ユーザーごとの通知受信ボックスエントリ", fields: { id: { - label: "在席 ID" - }, - created_at: { - label: "作成日時" - }, - updated_at: { - label: "更新日時" - }, - user_id: { - label: "ユーザー" - }, - session_id: { - label: "セッション" - }, - status: { - label: "ステータス", - options: { - online: "オンライン", - away: "離席中", - busy: "取り込み中", - offline: "オフライン" - } + label: "通知 ID" }, - last_seen: { - label: "最終確認日時" + topic: { + label: "Topic", + help: "Notification topic, e.g. task.assigned, collab.mention" }, - current_location: { - label: "現在地" + payload: { + label: "Payload", + help: "Template inputs carried to channels (title/body/url/actor/source/…)" }, - device: { - label: "デバイス", + severity: { + label: "Severity", + help: "Severity hint for rendering / filtering", options: { - desktop: "デスクトップ", - mobile: "モバイル", - tablet: "タブレット", - other: "その他" + info: "info", + warning: "warning", + critical: "critical" } }, - custom_status: { - label: "カスタムステータス" - }, - metadata: { - label: "メタデータ", - help: "在席状態に関連付けられた任意の JSON メタデータ(PresenceStateSchema.metadata に対応)。" - } - } - }, - sys_activity: { - label: "アクティビティ", - pluralLabel: "アクティビティ", - description: "最近のアクティビティストリームエントリ(軽量、非正規化)", - fields: { - id: { - label: "アクティビティ ID" - }, - timestamp: { - label: "タイムスタンプ" + dedup_key: { + label: "Dedup Key", + help: "Idempotency key within a topic window; a repeat emit is a no-op" }, - type: { - label: "タイプ", - options: { - created: "作成", - updated: "更新", - deleted: "削除", - commented: "コメント", - mentioned: "メンション", - shared: "共有", - assigned: "割り当て", - completed: "完了", - login: "ログイン", - logout: "ログアウト", - system: "システム" - } + source_object: { + label: "ソースオブジェクト", + help: "関連レコードのオブジェクト名(例: lead、opportunity)" }, - summary: { - label: "サマリー", - help: "判別しやすい 1 行サマリー" + source_id: { + label: "ソースレコード", + help: "source_object 内のレコード ID" }, actor_id: { - label: "操作者" - }, - actor_name: { - label: "操作者名" - }, - actor_avatar_url: { - label: "操作者アバター" - }, - object_name: { - label: "オブジェクト", - help: "対象オブジェクトの短い名前(例: account、sys_user)" - }, - record_id: { - label: "レコード ID" - }, - record_label: { - label: "レコード表示名", - help: "書き込み時点の対象レコードの表示名" - }, - url: { - label: "URL", - help: "アクティビティターゲットへのオプションのディープリンク" - }, - environment_id: { - label: "プロジェクト", - help: "プロジェクトコンテキスト(マルチプロジェクトデプロイメント)" - }, - metadata: { - label: "メタデータ", - help: "JSON シリアライズされた追加コンテキスト" - } - } - }, - sys_comment: { - label: "コメント", - pluralLabel: "コメント", - description: "thread_id を介してレコードに添付されたスレッドコメント", - fields: { - id: { - label: "コメント ID" - }, - thread_id: { - label: "スレッド", - help: "スレッド識別子 — 通常は `{object}:{record_id}`(例: `sys_user:abc123`)" - }, - parent_id: { - label: "親コメント", - help: "ネストした返信用のオプションの親コメント" - }, - reply_count: { - label: "返信数" - }, - author_id: { - label: "投稿者" - }, - author_name: { - label: "投稿者名" - }, - author_avatar_url: { - label: "投稿者アバター" - }, - body: { - label: "本文", - help: "コメントテキスト(Markdown 対応)" - }, - mentions: { - label: "メンション", - help: "@メンションオブジェクトの JSON 配列" - }, - reactions: { - label: "リアクション", - help: "絵文字リアクションオブジェクトの JSON 配列" - }, - is_edited: { - label: "編集済み" - }, - edited_at: { - label: "編集日時" - }, - visibility: { - label: "公開範囲", - options: { - public: "公開", - internal: "内部", - private: "非公開" - } + label: "操作者", + help: "通知を引き起こしたユーザー(メンション者、担当者)" }, created_at: { label: "作成日時" + } + }, + _views: { + recent: { + label: "Recent" }, - updated_at: { - label: "更新日時" + by_topic: { + label: "By Topic" } } }, @@ -1547,60 +1422,6 @@ export const jaJPObjects: NonNullable = { } } }, - sys_notification: { - label: "通知", - pluralLabel: "通知", - description: "ユーザーごとの通知受信ボックスエントリ", - fields: { - id: { - label: "通知 ID" - }, - topic: { - label: "Topic", - help: "Notification topic, e.g. task.assigned, collab.mention" - }, - payload: { - label: "Payload", - help: "Template inputs carried to channels (title/body/url/actor/source/…)" - }, - severity: { - label: "Severity", - help: "Severity hint for rendering / filtering", - options: { - info: "info", - warning: "warning", - critical: "critical" - } - }, - dedup_key: { - label: "Dedup Key", - help: "Idempotency key within a topic window; a repeat emit is a no-op" - }, - source_object: { - label: "ソースオブジェクト", - help: "関連レコードのオブジェクト名(例: lead、opportunity)" - }, - source_id: { - label: "ソースレコード", - help: "source_object 内のレコード ID" - }, - actor_id: { - label: "操作者", - help: "通知を引き起こしたユーザー(メンション者、担当者)" - }, - created_at: { - label: "作成日時" - } - }, - _views: { - recent: { - label: "Recent" - }, - by_topic: { - label: "By Topic" - } - } - }, sys_email: { label: "メール", pluralLabel: "メール", @@ -2505,6 +2326,11 @@ export const jaJPObjects: NonNullable = { label: "暗号文", help: "プロバイダーエンコードされた暗号文 blob(base64 / JSON)。実装定義のため、対応する ICryptoProvider のみが読み取り可能。" } + }, + _views: { + all: { + label: "All Secrets" + } } }, sys_setting_audit: { @@ -2580,6 +2406,11 @@ export const jaJPObjects: NonNullable = { label: "リクエスト ID", help: "sys_audit_log / トレーシングとの相関用。" } + }, + _views: { + recent: { + label: "Recent" + } } } }; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 39cb85a361..3cea691914 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -42,14 +42,64 @@ export const zhCNObjects: NonNullable = { label: "封禁到期时间", help: "设置后,到达该时间会自动解除封禁。" }, + failed_login_count: { + label: "Failed Login Count", + help: "Consecutive failed sign-in attempts; reset to 0 on success. Maintained by the auth manager." + }, + locked_until: { + label: "Locked Until", + help: "When set and in the future, sign-in is rejected (brute-force lockout). Auto-clears past this time; an admin can clear it early via Unlock." + }, + password_changed_at: { + label: "Password Changed At", + help: "Timestamp of the last password change. Backs password_expiry_days; system-managed." + }, + phone_number: { + label: "Phone Number", + help: "Sign-in phone number (E.164 recommended). Unique per user; managed by better-auth when the phoneNumber plugin is enabled." + }, + phone_number_verified: { + label: "Phone Verified", + help: "Whether the phone number has been verified (OTP verification requires SMS infrastructure; false until that ships). System-managed." + }, + must_change_password: { + label: "Must Change Password", + help: "When true, the user is blocked (403 PASSWORD_EXPIRED) until they change their password. Stamped by the admin user-management routes; system-managed." + }, + mfa_required_at: { + label: "MFA Required At", + help: "When enforced MFA first applied to this user (grace-period clock). Backs mfa_required; system-managed." + }, + last_login_at: { + label: "Last Login At", + help: "Timestamp of the last successful sign-in. Stamped by the auth manager; system-managed." + }, + last_login_ip: { + label: "Last Login IP", + help: "Client IP of the last successful sign-in (from the trusted proxy forwarded header). System-managed." + }, + ai_access: { + label: "AI Access", + help: "Whether this user holds an AI seat — grants access to the in-UI AI agents (build / ask). The framework synthesizes the `ai_seat` capability from this flag (plugin-hono-server resolveCtx). Assignment is capped by the licensed / purchased seat count (enforced by @objectstack/security-enterprise AiSeatPlugin). Owned by objectql (better-auth is oblivious to this column)." + }, image: { label: "头像" }, manager_id: { - label: "经理" + label: "经理", + help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)." }, primary_business_unit_id: { - label: "主属业务单元" + label: "主属业务单元", + help: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership." + }, + source: { + label: "Identity Source", + help: "How this identity was created — idp_provisioned (federated SSO JIT) or env_native (local signup / app end-user). System-managed; do not edit.", + options: { + idp_provisioned: "IdP-Provisioned", + env_native: "Env-Native" + } }, id: { label: "用户 ID" @@ -92,6 +142,14 @@ export const zhCNObjects: NonNullable = { label: "解除封禁", successMessage: "用户已解除封禁" }, + unlock_user: { + label: "Unlock Account", + successMessage: "Account unlocked" + }, + create_user: { + label: "Create User", + successMessage: "User created" + }, set_user_password: { label: "设置密码", successMessage: "密码已更新" @@ -153,6 +211,18 @@ export const zhCNObjects: NonNullable = { expires_at: { label: "过期时间" }, + last_activity_at: { + label: "Last Activity At", + help: "Timestamp of the last request on this session; drives idle-timeout. System-managed." + }, + revoked_at: { + label: "Revoked At", + help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed." + }, + revoke_reason: { + label: "Revoke Reason", + help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)." + }, active_organization_id: { label: "当前组织" }, @@ -251,6 +321,10 @@ export const zhCNObjects: NonNullable = { password: { label: "密码哈希", help: "邮箱/密码提供方使用的密码哈希" + }, + previous_password_hashes: { + label: "Previous Password Hashes", + help: "JSON array of prior password hashes (bounded by password_history_count); reuse-prevention only. System-managed." } }, _views: { @@ -321,6 +395,10 @@ export const zhCNObjects: NonNullable = { label: "元数据", help: "JSON 序列化的组织元数据" }, + require_mfa: { + label: "Require Multi-Factor Auth", + help: "When true, every member of this organization must enroll an authenticator app to access data." + }, id: { label: "组织 ID" }, @@ -645,6 +723,9 @@ export const zhCNObjects: NonNullable = { } }, _views: { + org_chart: { + label: "Org Chart" + }, active: { label: "启用" }, @@ -1226,263 +1307,57 @@ export const zhCNObjects: NonNullable = { } } }, - sys_audit_log: { - label: "审计日志", - pluralLabel: "审计日志", - description: "平台事件的不可变审计追踪", - fields: { - created_at: { - label: "时间戳" - }, - action: { - label: "操作", - help: "操作类型(snake_case)", - options: { - create: "创建", - update: "更新", - delete: "删除", - restore: "恢复", - login: "登录", - logout: "登出", - permission_change: "权限变更", - config_change: "配置变更", - export: "导出", - import: "导入" - } - }, - user_id: { - label: "执行人", - help: "执行该操作的用户(系统操作时为 null)" - }, - object_name: { - label: "对象", - help: "目标对象(例如 sys_user、project_task)" - }, - record_id: { - label: "记录 ID", - help: "受影响记录的 ID" - }, - old_value: { - label: "旧值", - help: "旧状态的 JSON 序列化内容" - }, - new_value: { - label: "新值", - help: "新状态的 JSON 序列化内容" - }, - ip_address: { - label: "IP 地址" - }, - user_agent: { - label: "用户代理" - }, - tenant_id: { - label: "租户", - help: "用于多租户隔离的租户上下文" - }, - metadata: { - label: "元数据", - help: "附加上下文的 JSON 序列化内容" - }, - id: { - label: "审计日志 ID" - } - }, - _views: { - recent: { - label: "最近" - }, - writes_only: { - label: "写入" - }, - auth_events: { - label: "认证" - }, - config_changes: { - label: "配置" - }, - all_events: { - label: "全部" - } - } - }, - sys_presence: { - label: "在线状态", - pluralLabel: "在线状态", - description: "实时用户在线与活动跟踪", + sys_notification: { + label: "通知", + pluralLabel: "通知", + description: "按用户存储的通知收件箱条目", fields: { id: { - label: "在线状态 ID" - }, - created_at: { - label: "创建时间" - }, - updated_at: { - label: "更新时间" - }, - user_id: { - label: "用户" - }, - session_id: { - label: "会话" - }, - status: { - label: "状态", - options: { - online: "在线", - away: "离开", - busy: "忙碌", - offline: "离线" - } + label: "通知 ID" }, - last_seen: { - label: "最近在线时间" + topic: { + label: "Topic", + help: "Notification topic, e.g. task.assigned, collab.mention" }, - current_location: { - label: "当前位置" + payload: { + label: "Payload", + help: "Template inputs carried to channels (title/body/url/actor/source/…)" }, - device: { - label: "设备", + severity: { + label: "Severity", + help: "Severity hint for rendering / filtering", options: { - desktop: "桌面端", - mobile: "移动端", - tablet: "平板端", - other: "其他" + info: "info", + warning: "warning", + critical: "critical" } }, - custom_status: { - label: "自定义状态" - }, - metadata: { - label: "元数据", - help: "与在线状态关联的任意 JSON 元数据(对应 PresenceStateSchema.metadata)。" - } - } - }, - sys_activity: { - label: "活动", - pluralLabel: "活动", - description: "最近活动流条目(轻量、去规范化)", - fields: { - id: { - label: "活动 ID" - }, - timestamp: { - label: "时间戳" + dedup_key: { + label: "Dedup Key", + help: "Idempotency key within a topic window; a repeat emit is a no-op" }, - type: { - label: "类型", - options: { - created: "已创建", - updated: "已更新", - deleted: "已删除", - commented: "已评论", - mentioned: "被提及", - shared: "已共享", - assigned: "已分配", - completed: "已完成", - login: "登录", - logout: "登出", - system: "系统" - } + source_object: { + label: "来源对象", + help: "关联记录的对象名称(例如 lead、opportunity)" }, - summary: { - label: "摘要", - help: "人类可读的单行摘要" + source_id: { + label: "来源记录", + help: "source_object 中的记录 ID" }, actor_id: { - label: "执行人" - }, - actor_name: { - label: "执行人名称" - }, - actor_avatar_url: { - label: "执行人头像" - }, - object_name: { - label: "对象", - help: "目标对象短名称(例如 account、sys_user)" - }, - record_id: { - label: "记录 ID" - }, - record_label: { - label: "记录标签", - help: "写入时目标记录的显示标签" - }, - url: { - label: "URL", - help: "指向活动目标的可选深度链接" - }, - environment_id: { - label: "项目", - help: "项目上下文(多项目部署)" - }, - metadata: { - label: "元数据", - help: "附加上下文的 JSON 序列化内容" - } - } - }, - sys_comment: { - label: "评论", - pluralLabel: "评论", - description: "通过 thread_id 附加到记录的线程化评论", - fields: { - id: { - label: "评论 ID" - }, - thread_id: { - label: "线程", - help: "线程标识——约定格式为 `{object}:{record_id}`(例如 `sys_user:abc123`)" - }, - parent_id: { - label: "父评论", - help: "可选的父评论,用于嵌套回复" - }, - reply_count: { - label: "回复数" - }, - author_id: { - label: "作者" - }, - author_name: { - label: "作者名称" - }, - author_avatar_url: { - label: "作者头像" - }, - body: { - label: "正文", - help: "评论文本(支持 Markdown)" - }, - mentions: { - label: "提及", - help: "@mention 对象的 JSON 数组" - }, - reactions: { - label: "回应", - help: "表情回应对象的 JSON 数组" - }, - is_edited: { - label: "已编辑" - }, - edited_at: { - label: "编辑时间" - }, - visibility: { - label: "可见性", - options: { - public: "公开", - internal: "内部", - private: "私有" - } + label: "执行人", + help: "触发该通知的用户(提及人、分配人)" }, created_at: { label: "创建时间" + } + }, + _views: { + recent: { + label: "Recent" }, - updated_at: { - label: "更新时间" + by_topic: { + label: "By Topic" } } }, @@ -1547,60 +1422,6 @@ export const zhCNObjects: NonNullable = { } } }, - sys_notification: { - label: "通知", - pluralLabel: "通知", - description: "按用户存储的通知收件箱条目", - fields: { - id: { - label: "通知 ID" - }, - topic: { - label: "Topic", - help: "Notification topic, e.g. task.assigned, collab.mention" - }, - payload: { - label: "Payload", - help: "Template inputs carried to channels (title/body/url/actor/source/…)" - }, - severity: { - label: "Severity", - help: "Severity hint for rendering / filtering", - options: { - info: "info", - warning: "warning", - critical: "critical" - } - }, - dedup_key: { - label: "Dedup Key", - help: "Idempotency key within a topic window; a repeat emit is a no-op" - }, - source_object: { - label: "来源对象", - help: "关联记录的对象名称(例如 lead、opportunity)" - }, - source_id: { - label: "来源记录", - help: "source_object 中的记录 ID" - }, - actor_id: { - label: "执行人", - help: "触发该通知的用户(提及人、分配人)" - }, - created_at: { - label: "创建时间" - } - }, - _views: { - recent: { - label: "Recent" - }, - by_topic: { - label: "By Topic" - } - } - }, sys_email: { label: "邮件", pluralLabel: "邮件", @@ -2505,6 +2326,11 @@ export const zhCNObjects: NonNullable = { label: "密文", help: "提供方编码后的密文数据(base64 / JSON)。实现定义;仅匹配的 ICryptoProvider 可读取。" } + }, + _views: { + all: { + label: "All Secrets" + } } }, sys_setting_audit: { @@ -2580,6 +2406,11 @@ export const zhCNObjects: NonNullable = { label: "请求 ID", help: "与 sys_audit_log / tracing 关联。" } + }, + _views: { + recent: { + label: "Recent" + } } } }; diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index 5322afdedc..7a8fbc07bc 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -25,7 +25,6 @@ export { InMemorySuspendedRunStore, ObjectStoreSuspendedRunStore, DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW, - DEFAULT_RUN_HISTORY_RETENTION_DAYS, } from './suspended-run-store.js'; export type { SuspendedRunStoreEngine, ObjectStoreSuspendedRunStoreOptions } from './suspended-run-store.js'; diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index a0e83bc64c..95ebd34ae5 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -8,7 +8,6 @@ import { SysAutomationRun } from './sys-automation-run.object.js'; import { ObjectStoreSuspendedRunStore, DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW, - DEFAULT_RUN_HISTORY_RETENTION_DAYS, type SuspendedRunStoreEngine, } from './suspended-run-store.js'; @@ -36,26 +35,20 @@ export interface AutomationServicePluginOptions { * to {@link DEFAULT_MAX_EXECUTION_LOG_SIZE} (1000). */ maxLogSize?: number; - /** - * Retention window in days for durable terminal run history in - * `sys_automation_run` (#2585; ADR-0057 posture — platform self-telemetry - * must be bounded). When > 0, a periodic sweep deletes terminal - * (completed / failed) history rows older than the window; suspended - * (`paused`) rows are live resumable state and are never pruned. - * **Default-on** at {@link DEFAULT_RUN_HISTORY_RETENTION_DAYS} (30). Set - * to `0` to disable age pruning (history kept until the per-flow cap). - */ - runHistoryRetentionDays?: number; /** * Per-flow cap on terminal run-history rows, enforced at write time (the * "or 100 runs/flow, whichever first" half of the #2585 retention * contract). Defaults to {@link DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW} (100); * `0` disables the cap. + * + * The AGE half of the contract is declarative since ADR-0057 (#2834): + * `sys_automation_run` declares `retention: { maxAge: '30d', onlyWhen: + * { status: { $in: ['completed', 'failed'] } } }` and the platform + * LifecycleService enforces it — suspended (`paused`) rows stay outside + * the filter and are retained regardless of age. Tune the window via the + * `lifecycle` settings namespace (`retention_overrides`). */ runHistoryMaxPerFlow?: number; - /** Run-history retention sweep interval in ms (default 1 hour). Only used - * when `runHistoryRetentionDays` > 0. */ - runHistorySweepMs?: number; } /** @@ -97,8 +90,6 @@ export class AutomationServicePlugin implements Plugin { private engine?: AutomationEngine; private readonly options: AutomationServicePluginOptions; - /** Periodic run-history retention sweep (#2585); cleared on destroy. */ - private retentionTimer?: ReturnType; /** * Flow names this plugin has registered into the engine from the * artifact / ObjectQL registry, tracked so a `metadata:reloaded` re-sync @@ -193,35 +184,13 @@ export class AutomationServicePlugin implements Plugin { } } - // Run-history age retention (#2585, ADR-0057 posture): default-on sweep - // so `sys_automation_run` terminal history can't grow without bound. - // Runs once at kernel:ready then on a low-frequency interval; the timer - // is unref'd so it never keeps the process alive. Mirrors the - // service-messaging notification retention sweep. - const retentionDays = this.options.runHistoryRetentionDays ?? DEFAULT_RUN_HISTORY_RETENTION_DAYS; - if (durableStore && retentionDays > 0 && typeof ctx.hook === 'function') { - const store = durableStore; - const sweepMs = this.options.runHistorySweepMs ?? 3_600_000; - ctx.hook('kernel:ready', async () => { - const sweep = () => { - void store.pruneHistory(retentionDays).then((deleted) => { - if (deleted === undefined || deleted > 0) { - ctx.logger.info( - `[Automation] run-history retention: pruned ${deleted ?? '?'} terminal run(s) older than ${retentionDays}d`, - ); - } - }).catch((err) => - ctx.logger.warn(`[Automation] run-history retention sweep failed: ${(err as Error)?.message ?? err}`), - ); - }; - sweep(); - this.retentionTimer = setInterval(sweep, sweepMs); - this.retentionTimer.unref?.(); - ctx.logger.info( - `[Automation] run-history retention on (terminal runs > ${retentionDays}d pruned every ${Math.round(sweepMs / 1000)}s; cap ${this.options.runHistoryMaxPerFlow ?? DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW}/flow at write)`, - ); - }); - } + // Run-history AGE retention is declarative since ADR-0057 (#2834): + // sys_automation_run carries `retention: { maxAge: '30d', onlyWhen: + // { status: { $in: ['completed', 'failed'] } } }` and the platform + // Reaper sweeps it — the status predicate keeps suspended approval + // runs alive indefinitely, exactly like the old pruneHistory loop did. + // Only the write-time per-flow overflow cap stays here (it is a + // count-based bound the declarative contract can't express). // #1870 — bridge `script`-node function calls to the host function // registry. ObjectQL holds the name→handler map populated from @@ -482,10 +451,6 @@ export class AutomationServicePlugin implements Plugin { } async destroy(): Promise { - if (this.retentionTimer) { - clearInterval(this.retentionTimer); - this.retentionTimer = undefined; - } this.engine = undefined; } } diff --git a/packages/services/service-automation/src/suspended-run-store.test.ts b/packages/services/service-automation/src/suspended-run-store.test.ts index 1518f49ce7..fc4a006e22 100644 --- a/packages/services/service-automation/src/suspended-run-store.test.ts +++ b/packages/services/service-automation/src/suspended-run-store.test.ts @@ -17,7 +17,7 @@ function createTestLogger() { */ function createFakeEngine(): SuspendedRunStoreEngine & { rows: Map } { const rows = new Map(); - // Equality plus the `$lt` operator the retention sweep uses. + // Equality plus the `$lt` operator (kept for where-clause generality). const matches = (row: any, where: any) => !where || Object.entries(where).every(([k, v]) => v && typeof v === 'object' && '$lt' in (v as any) @@ -233,39 +233,6 @@ describe('ObjectStoreSuspendedRunStore — run-history retention + durable detai expect(await store.listHistory('busy_flow', 10)).toHaveLength(3); }); - it('pruneHistory deletes terminal rows past the age window but never paused rows', async () => { - const engine = createFakeEngine(); - const store = new ObjectStoreSuspendedRunStore(engine, createTestLogger()); - const now = Date.parse('2026-03-01T00:00:00.000Z'); - // Backdate created_at: two ancient terminals (one failed), one fresh. - await store.recordTerminal(terminalRecord(1)); - await store.recordTerminal(terminalRecord(2, { status: 'failed', error: 'x' })); - await store.recordTerminal(terminalRecord(3)); - engine.rows.get('run_r1').created_at = '2026-01-01T00:00:00.000Z'; - engine.rows.get('run_r2').created_at = '2026-01-02T00:00:00.000Z'; - engine.rows.get('run_r3').created_at = '2026-02-28T00:00:00.000Z'; - // An old suspended run — live resumable state, exempt from retention. - await store.save(baseRun()); - engine.rows.get('run_abc').created_at = '2025-06-01T00:00:00.000Z'; - - const deleted = await store.pruneHistory(30, now); - expect(deleted).toBe(2); - expect(await store.loadTerminal('r1')).toBeNull(); - expect(await store.loadTerminal('r2')).toBeNull(); - expect(await store.loadTerminal('r3')).not.toBeNull(); - expect(await store.load('run_abc')).not.toBeNull(); - }); - - it('pruneHistory is a no-op for a non-positive window', async () => { - const engine = createFakeEngine(); - const store = new ObjectStoreSuspendedRunStore(engine, createTestLogger()); - await store.recordTerminal(terminalRecord(1)); - engine.rows.get('run_r1').created_at = '2020-01-01T00:00:00.000Z'; - expect(await store.pruneHistory(0)).toBe(0); - expect(await store.pruneHistory(-5)).toBe(0); - expect(await store.loadTerminal('r1')).not.toBeNull(); - }); - it('bounds steps_json bytes by dropping the oldest steps first', async () => { const engine = createFakeEngine(); const store = new ObjectStoreSuspendedRunStore(engine, createTestLogger()); diff --git a/packages/services/service-automation/src/suspended-run-store.ts b/packages/services/service-automation/src/suspended-run-store.ts index 498e1dd359..ccf62274c4 100644 --- a/packages/services/service-automation/src/suspended-run-store.ts +++ b/packages/services/service-automation/src/suspended-run-store.ts @@ -27,23 +27,20 @@ const HISTORY_PREFIX = 'run_'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; /** - * Default per-flow cap on terminal run-history rows (#2585 retention stop-gap - * until the ADR-0057 lifecycle sweep covers `sys_automation_run`). A busy + * Default per-flow cap on terminal run-history rows (#2585). A busy * per-record-change flow otherwise persists one row per execution forever — * exactly the unbounded self-telemetry growth ADR-0057 exists to prevent. * 100 newest terminal runs per flow keeps the Runs surface useful while * bounding the table. `0` disables the cap. + * + * This write-time COUNT bound is the only retention this store enforces. + * AGE retention is declarative (#2834): `sys_automation_run` declares + * `retention: { maxAge, onlyWhen: { status: { $in: [...] } } }` and the + * platform Reaper sweeps it — suspended (`paused`) rows are live resumable + * state and never match the predicate. */ export const DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW = 100; -/** - * Default age-based retention window for terminal run-history rows, in days. - * Enforced by {@link ObjectStoreSuspendedRunStore.pruneHistory}, swept - * periodically by the service plugin. `0` disables age pruning. Suspended - * (`paused`) rows are live resumable state and are NEVER age-pruned. - */ -export const DEFAULT_RUN_HISTORY_RETENTION_DAYS = 30; - /** Max deletes one write-time overflow prune may issue — bounds the write * amplification a single `recordTerminal` can incur on a legacy oversized * table (the periodic age sweep handles bulk convergence). */ @@ -53,8 +50,6 @@ const OVERFLOW_PRUNE_BATCH = 50; * tail is halved until it fits — the newest steps carry the failure. */ const MAX_STEPS_JSON_BYTES = 64 * 1024; -const TERMINAL_STATUSES = ['completed', 'failed'] as const; - function isTerminalStatus(status: unknown): boolean { return status === 'completed' || status === 'failed'; } @@ -309,32 +304,6 @@ export class ObjectStoreSuspendedRunStore implements SuspendedRunStore { } } - /** - * Age-based retention sweep (#2585, ADR-0057 posture): delete terminal - * history rows older than `retentionDays`. Two equality-filtered bulk - * deletes (one per terminal status) so `paused` rows — live resumable state — - * can never match. Returns the number of rows deleted when the driver - * reports it. No-op for a non-positive window or a delete-less engine. - */ - async pruneHistory(retentionDays: number, now: number = Date.now()): Promise { - if (!(retentionDays > 0) || typeof this.engine.delete !== 'function') return 0; - const cutoffIso = new Date(now - retentionDays * 86_400_000).toISOString(); - let total: number | undefined = 0; - for (const status of TERMINAL_STATUSES) { - // ISO-8601 comparand: `created_at` is a native timestamp column, which - // rejects a bare epoch-ms number on Postgres (same convention as the - // ADR-0057 LifecycleService Reaper). - const res = await this.engine.delete(TABLE, { - where: { status, created_at: { $lt: cutoffIso } }, - multi: true, - context: SYSTEM_CTX, - }); - const n = countDeleted(res); - total = n === undefined || total === undefined ? undefined : total + n; - } - return total; - } - /** Load one terminal history row by raw `runId` (durable `getRun` fallback). */ async loadTerminal(runId: string): Promise { const rows = await this.engine.find(TABLE, { @@ -436,17 +405,3 @@ function serializeStepsBounded(steps: RunRecord['steps']): string | null { } return null; } - -/** Best-effort row-count extraction from a driver's delete result (mirrors - * service-messaging's retention sweeper). */ -function countDeleted(res: unknown): number | undefined { - if (typeof res === 'number') return res; - if (Array.isArray(res)) return res.length; - if (res && typeof res === 'object') { - const r = res as Record; - for (const k of ['deletedCount', 'deleted', 'count', 'affected', 'affectedRows']) { - if (typeof r[k] === 'number') return r[k] as number; - } - } - return undefined; -} diff --git a/packages/services/service-automation/src/sys-automation-run.object.ts b/packages/services/service-automation/src/sys-automation-run.object.ts index 36a1a93673..dcc57d3dfc 100644 --- a/packages/services/service-automation/src/sys-automation-run.object.ts +++ b/packages/services/service-automation/src/sys-automation-run.object.ts @@ -40,15 +40,21 @@ export const SysAutomationRun = ObjectSchema.create({ icon: 'pause-circle', isSystem: true, managedBy: 'system', - // ADR-0057: deliberately NO `lifecycle` block. This is a MIXED table — live - // suspended runs (resumable workflow state, record semantics: an approval - // may legitimately stay paused for months) interleaved with terminal run - // history (telemetry semantics). A blanket age-based retention would reap - // suspended runs and strand in-flight approvals; the declarative contract - // has no status predicate yet. Bounding is owned by the automation store's - // specialized default-on sweep instead (ObjectStoreSuspendedRunStore - // .pruneHistory, #2585): TERMINAL statuses only, by age + per-flow cap — - // suspended rows are never age-pruned. + // ADR-0057 (#2834): MIXED table — live suspended runs (resumable workflow + // state, record semantics: an approval may legitimately stay paused for + // months) interleaved with terminal run history (telemetry semantics). + // `retention.onlyWhen` scopes the age sweep to TERMINAL statuses only, so + // the platform Reaper prunes 30d-old history while suspended (`paused`) and + // in-flight (`running`) rows never match. The write-time per-flow overflow + // cap (ObjectStoreSuspendedRunStore.pruneFlowOverflow, #2585) stays in the + // store — a count bound the declarative contract can't express. + lifecycle: { + class: 'telemetry', + retention: { + maxAge: '30d', + onlyWhen: { status: { $in: ['completed', 'failed'] } }, + }, + }, description: 'Durable automation run state: live suspended runs (resumable, ADR-0019) and terminal run history (completed / failed, for observability).', displayNameField: 'id', nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 77629bc3d0..4314ade059 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -102,6 +102,49 @@ describe('LifecycleSchema (ADR-0057)', () => { } }); + it('accepts retention.onlyWhen with scalar and $in predicates (#2834 mixed tables)', () => { + const result = LifecycleSchema.safeParse({ + class: 'telemetry', + retention: { + maxAge: '30d', + onlyWhen: { status: { $in: ['completed', 'failed'] }, archived: true }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects onlyWhen operators other than $in and empty $in lists', () => { + for (const bad of [ + { status: { $nin: ['paused'] } }, // unsupported operator + { status: { $in: [] } }, // empty list matches nothing — surely a mistake + { status: { $in: ['a'], extra: 1 } }, // strict object: no extra keys + ]) { + const result = LifecycleSchema.safeParse({ + class: 'telemetry', + retention: { maxAge: '30d', onlyWhen: bad }, + }); + expect(result.success).toBe(false); + } + }); + + it('rejects onlyWhen combined with rotation storage (shard DROPs ignore filters)', () => { + const result = LifecycleSchema.safeParse({ + class: 'telemetry', + retention: { maxAge: '14d', onlyWhen: { status: 'done' } }, + storage: { strategy: 'rotation', shards: 14, unit: 'day' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects onlyWhen combined with archive (the Archiver moves rows by age alone)', () => { + const result = LifecycleSchema.safeParse({ + class: 'audit', + retention: { maxAge: '90d', onlyWhen: { status: 'done' } }, + archive: { after: '90d', to: 'datalake' }, + }); + expect(result.success).toBe(false); + }); + it('is accepted as an object-level property by ObjectSchema.create', () => { const obj = ObjectSchema.create({ name: 'my_trace', diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 5b9838edb0..4dc770f32d 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -306,6 +306,17 @@ export const LifecycleSchema = lazySchema(() => z.object({ ), retention: z.object({ maxAge: lifecycleDuration('retention.maxAge').describe('Rows older than this (by created_at) are deleted by the Reaper — or archived first when `archive` is set.'), + onlyWhen: z.record( + z.string(), + z.union([ + z.string(), + z.number(), + z.boolean(), + z.object({ $in: z.array(z.union([z.string(), z.number()])).min(1) }).strict(), + ]), + ).optional().describe( + 'Row filter the retention applies to — per-field equality or {$in: [...]} (e.g. { status: { $in: ["completed", "failed"] } }). Rows OUTSIDE the filter are retained regardless of age: for tables that interleave live workflow state with terminal history (sys_automation_run). Incompatible with rotation storage and archive, which act on whole shards / age alone.', + ), }).optional().describe('Age-based retention window enforced by the LifecycleService Reaper.'), ttl: z.object({ field: z.string().describe('Timestamp field the TTL is measured from (e.g. created_at, expires_at).'), @@ -343,6 +354,18 @@ export const LifecycleSchema = lazySchema(() => z.object({ message: `lifecycle.archive.after ('${lc.archive.after}') must equal retention.maxAge ('${lc.retention.maxAge}') — the hot window ends where the archive begins`, }); } + if (lc.retention?.onlyWhen && lc.storage?.strategy === 'rotation') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'lifecycle.retention.onlyWhen cannot be combined with rotation storage — the Rotator DROPs whole shards and would destroy rows the filter protects', + }); + } + if (lc.retention?.onlyWhen && lc.archive) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'lifecycle.retention.onlyWhen cannot be combined with archive — the Archiver moves rows by age alone and would archive rows the filter protects', + }); + } })); diff --git a/skills/objectstack-data/rules/lifecycle.md b/skills/objectstack-data/rules/lifecycle.md index aaeb87ee22..34ab013667 100644 --- a/skills/objectstack-data/rules/lifecycle.md +++ b/skills/objectstack-data/rules/lifecycle.md @@ -72,6 +72,22 @@ export const ConsentLog = ObjectSchema.create({ subject: Field.text({}), }, }); + +// ✅ MIXED table: terminal history is telemetry, but paused rows are live +// workflow state — `onlyWhen` scopes the age sweep to the declared filter +export const AutomationRun = ObjectSchema.create({ + name: 'sys_automation_run', + lifecycle: { + class: 'telemetry', + retention: { + maxAge: '30d', + onlyWhen: { status: { $in: ['completed', 'failed'] } }, // paused rows never reaped + }, + }, + fields: { + status: Field.select(['running', 'paused', 'completed', 'failed'], {}), + }, +}); ``` Duration literals: `` + `h` / `d` / `w` / `y` — e.g. `'6h'`, `'14d'`, @@ -91,6 +107,14 @@ lifecycle: { class: 'audit', retention: { maxAge: '90d' }, archive: { after: '30 // ❌ Free-form durations lifecycle: { class: 'telemetry', retention: { maxAge: '2 weeks' } } // use '2w' + +// ❌ onlyWhen + rotation/archive — shard DROPs and the Archiver act on age +// alone and would destroy/move rows the filter protects +lifecycle: { + class: 'telemetry', + retention: { maxAge: '14d', onlyWhen: { status: 'done' } }, + storage: { strategy: 'rotation', shards: 14, unit: 'day' }, +} ``` ## Safety Semantics @@ -127,3 +151,6 @@ lifecycle: { class: 'telemetry', retention: { maxAge: '2 weeks' } } // use '2w' 3. Written by machines at high frequency, value decays in days/weeks? → `telemetry` + `retention` (add rotation `storage` for the hottest tables). 4. Meaningless after a deadline (tokens, receipts, read-state)? → `transient` + `ttl` on the natural expiry field. 5. Bus/fan-out messages? → `event` + a short `ttl` (hours). +6. Table mixes prunable history with live state (e.g. terminal vs paused runs)? + → add `retention.onlyWhen: { status: { $in: [...] } }` so rows outside the + filter are retained regardless of age. Not combinable with rotation/archive.