@pikku/core 0.12.97 → 0.12.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/classification/data-classification.d.ts +10 -0
  3. package/dist/classification/data-lock.d.ts +80 -0
  4. package/dist/classification/data-lock.js +146 -0
  5. package/dist/classification/index.d.ts +1 -0
  6. package/dist/classification/index.js +1 -0
  7. package/dist/classification/key-ids.d.ts +2 -0
  8. package/dist/classification/key-ids.js +2 -0
  9. package/dist/middleware/index.d.ts +1 -1
  10. package/dist/middleware/index.js +1 -1
  11. package/dist/middleware/require-unlocked.d.ts +23 -0
  12. package/dist/middleware/require-unlocked.js +21 -0
  13. package/dist/services/http-personas.d.ts +10 -4
  14. package/dist/services/index.d.ts +1 -0
  15. package/dist/services/index.js +1 -0
  16. package/dist/services/persona-actor-secret.d.ts +38 -0
  17. package/dist/services/persona-actor-secret.js +39 -0
  18. package/dist/services/persona-sign-in.d.ts +11 -1
  19. package/dist/services/persona-sign-in.js +10 -1
  20. package/dist/services/typed-secret-service.js +4 -1
  21. package/dist/wirings/agent-scorer/agent-scorer.d.ts +14 -0
  22. package/dist/wirings/data-lock/data-lock-wiring.d.ts +40 -0
  23. package/dist/wirings/data-lock/data-lock-wiring.js +77 -0
  24. package/dist/wirings/data-lock/index.d.ts +9 -0
  25. package/dist/wirings/data-lock/index.js +8 -0
  26. package/dist/wirings/gateway/gateway.types.d.ts +13 -0
  27. package/dist/wirings/persona/index.d.ts +2 -1
  28. package/dist/wirings/persona/index.js +1 -0
  29. package/dist/wirings/secret/secret.types.d.ts +8 -0
  30. package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +7 -1
  31. package/dist/wirings/virtual-user/virtual-user-scaffold.js +25 -3
  32. package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +1 -1
  33. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-but-it-needs-a-trigger.md +65 -0
  34. package/knowledge/decisions/internals/index.md +1 -1
  35. package/knowledge/decisions/security/actor-sign-in-only-works-for-actor-flagged-users.md +19 -15
  36. package/knowledge/decisions/security/an-actor-credential-is-derived-per-persona.md +41 -0
  37. package/knowledge/decisions/security/index.md +2 -1
  38. package/package.json +4 -4
  39. package/src/classification/data-classification.ts +10 -0
  40. package/src/classification/index.ts +2 -0
  41. package/src/classification/key-ids.ts +2 -0
  42. package/src/middleware/index.ts +0 -1
  43. package/src/public-surface.json +13 -1
  44. package/src/services/http-personas-converse.test.ts +3 -3
  45. package/src/services/http-personas.test.ts +13 -5
  46. package/src/services/http-personas.ts +10 -3
  47. package/src/services/index.ts +8 -0
  48. package/src/services/persona-actor-secret.test.ts +68 -0
  49. package/src/services/persona-actor-secret.ts +70 -0
  50. package/src/services/persona-sign-in.ts +20 -2
  51. package/src/services/typed-secret-service.test.ts +26 -1
  52. package/src/services/typed-secret-service.ts +4 -1
  53. package/src/wirings/agent-scorer/agent-scorer.ts +14 -0
  54. package/src/wirings/gateway/gateway.types.ts +20 -1
  55. package/src/wirings/persona/index.ts +9 -0
  56. package/src/wirings/secret/secret.types.ts +8 -0
  57. package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +59 -0
  58. package/src/wirings/virtual-user/virtual-user-scaffold.ts +37 -2
  59. package/tsconfig.tsbuildinfo +1 -1
  60. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +0 -53
@@ -6,7 +6,9 @@ import type { JudgeToolCallDisclosure, PikkuAgentScorer, ScorerInput, ScorerOutp
6
6
  * @example snippet: agentScorer
7
7
  */
8
8
  export declare const pikkuAgentScorer: <Services = any>(config: {
9
+ /** Identifies the scorer in results and in the Console. Unique per project. */
9
10
  name: string;
11
+ /** What this scorer grades, in one line, for whoever reads the score later. */
10
12
  description: string;
11
13
  /** 0..1 fraction of live runs to grade. Defaults to all of them. */
12
14
  sampleRate?: number;
@@ -15,6 +17,10 @@ export declare const pikkuAgentScorer: <Services = any>(config: {
15
17
  * traffic has no answer key, so the runtime never samples it.
16
18
  */
17
19
  requiresReference?: boolean;
20
+ /**
21
+ * The grade itself: read the finished run and return `{ score, reason }`.
22
+ * Runs in-process, so it may use your own services.
23
+ */
18
24
  score: (input: ScorerInput, services: Services) => ScorerOutput | Promise<ScorerOutput>;
19
25
  }) => PikkuAgentScorer<Services>;
20
26
  /**
@@ -28,7 +34,9 @@ export declare const pikkuAgentScorer: <Services = any>(config: {
28
34
  * @example snippet: agentJudge
29
35
  */
30
36
  export declare const pikkuAgentJudge: <Services = any>(config: {
37
+ /** Identifies the judge in results and in the Console. Unique per project. */
31
38
  name: string;
39
+ /** What this judge grades, in one line, for whoever reads the score later. */
32
40
  description: string;
33
41
  /** 0..1 fraction of live runs to grade. Defaults to all of them. */
34
42
  sampleRate?: number;
@@ -37,7 +45,9 @@ export declare const pikkuAgentJudge: <Services = any>(config: {
37
45
  * traffic has no answer key, so the runtime never samples it.
38
46
  */
39
47
  requiresReference?: boolean;
48
+ /** The model that grades, e.g. `'claude-sonnet-4-5'`. Not the model under test. */
40
49
  model: string;
50
+ /** The rubric: what a good answer looks like, phrased as the goal it should meet. */
41
51
  goal: string;
42
52
  /**
43
53
  * How much of the run's trajectory to disclose to the judge. Defaults to
@@ -45,5 +55,9 @@ export declare const pikkuAgentJudge: <Services = any>(config: {
45
55
  * sending a third-party model the rows the tools returned.
46
56
  */
47
57
  toolCalls?: JudgeToolCallDisclosure;
58
+ /**
59
+ * Replaces the generated rubric prompt outright, for framing `goal` cannot
60
+ * express. The `{ score, reason }` response is still forced.
61
+ */
48
62
  prompt?: (input: ScorerInput) => string;
49
63
  }) => PikkuAgentScorer<Services>;
@@ -0,0 +1,40 @@
1
+ import type { DataLock, LockState } from '../../classification/data-lock.js';
2
+ export type DataLockStatus = {
3
+ state: LockState;
4
+ /**
5
+ * Milliseconds before another guess will be looked at, or 0.
6
+ *
7
+ * The unlock screen shows this as a countdown; without it the only way to
8
+ * learn the wait is over is to guess again, and a guess made during a
9
+ * lockout is itself a failure that extends it.
10
+ */
11
+ retryAfterMs: number;
12
+ };
13
+ export type DataLockWiringOptions = {
14
+ /** Where the lock routes are mounted. Defaults to `/_pikku/data`. */
15
+ prefix?: string;
16
+ /**
17
+ * Which keys first-run initialization mints. Derive it with
18
+ * `keyIdsFromManifest`.
19
+ *
20
+ * It is fixed here rather than sent by the caller because the unlock screen
21
+ * posts a passphrase and nothing else — and because a key the schema names
22
+ * but nobody minted does not fail at startup, it fails at the first write to
23
+ * that one column.
24
+ */
25
+ keyIds?: string[];
26
+ };
27
+ /**
28
+ * Puts the passphrase gate on HTTP, so unlocking is a page in the app rather
29
+ * than a prompt in whatever happens to have launched the server.
30
+ *
31
+ * That is what lets one story cover both shapes pikku ships in: a desktop
32
+ * build whose window is pointed at the local server, and a headless
33
+ * `pikku serve` somewhere else, unlock the same way and share the unlock
34
+ * screen. A native prompt in the desktop shell would have left the headless
35
+ * case with nothing.
36
+ *
37
+ * The routes are registered here rather than generated because they belong to
38
+ * core: an app has no source file for them to be discovered in.
39
+ */
40
+ export declare const wireDataLock: (lock: DataLock, { prefix, keyIds }?: DataLockWiringOptions) => void;
@@ -0,0 +1,77 @@
1
+ import { pikkuState } from '../../pikku-state.js';
2
+ import { wireHTTP } from '../http/http-runner.js';
3
+ import { httpRouter } from '../http/routers/http-router.js';
4
+ const helperFunctionMeta = (funcId) => ({
5
+ pikkuFuncId: funcId,
6
+ sessionless: true,
7
+ functionType: 'helper',
8
+ inputSchemaName: null,
9
+ outputSchemaName: null,
10
+ });
11
+ const DEFAULT_PREFIX = '/_pikku/data';
12
+ /**
13
+ * Puts the passphrase gate on HTTP, so unlocking is a page in the app rather
14
+ * than a prompt in whatever happens to have launched the server.
15
+ *
16
+ * That is what lets one story cover both shapes pikku ships in: a desktop
17
+ * build whose window is pointed at the local server, and a headless
18
+ * `pikku serve` somewhere else, unlock the same way and share the unlock
19
+ * screen. A native prompt in the desktop shell would have left the headless
20
+ * case with nothing.
21
+ *
22
+ * The routes are registered here rather than generated because they belong to
23
+ * core: an app has no source file for them to be discovered in.
24
+ */
25
+ export const wireDataLock = (lock, { prefix = DEFAULT_PREFIX, keyIds } = {}) => {
26
+ const status = () => ({
27
+ state: lock.state,
28
+ retryAfterMs: lock.retryAfterMs,
29
+ });
30
+ register(prefix, 'get', '/status', 'pikkuDataLockStatus', async () => status());
31
+ register(prefix, 'post', '/initialize', 'pikkuDataLockInitialize', async (_services, { passphrase }) => {
32
+ await lock.initialize(passphrase, keyIds);
33
+ return status();
34
+ });
35
+ register(prefix, 'post', '/unlock', 'pikkuDataLockUnlock', async (_services, { passphrase }) => {
36
+ await lock.unlock(passphrase);
37
+ return status();
38
+ });
39
+ register(prefix, 'post', '/lock', 'pikkuDataLockLock', async (_services, { passphrase }) => {
40
+ // Locking proves ownership first. An open POST here would be a
41
+ // one-request denial of service: the store shuts and stays shut until
42
+ // someone is around to type the passphrase back in.
43
+ await lock.unlock(passphrase);
44
+ lock.lock();
45
+ return status();
46
+ });
47
+ // A router that has already compiled its table would otherwise answer 404
48
+ // for everything registered after it woke up.
49
+ httpRouter.reset();
50
+ };
51
+ const register = (prefix, method, path, funcId, func) => {
52
+ const route = `${prefix}${path}`;
53
+ const routes = pikkuState(null, 'http', 'routes');
54
+ if (routes.get(method)?.has(route)) {
55
+ return;
56
+ }
57
+ const httpMeta = pikkuState(null, 'http', 'meta');
58
+ httpMeta[method][route] = {
59
+ pikkuFuncId: funcId,
60
+ route,
61
+ method,
62
+ // Never a session. The gate cannot sit in front of its own key: a session
63
+ // may itself live in a column this lock is holding shut.
64
+ auth: false,
65
+ requiresSession: false,
66
+ };
67
+ const functionsMeta = pikkuState(null, 'function', 'meta');
68
+ if (!functionsMeta[funcId]) {
69
+ functionsMeta[funcId] = helperFunctionMeta(funcId);
70
+ }
71
+ wireHTTP({
72
+ method,
73
+ route,
74
+ func: { func },
75
+ auth: false,
76
+ });
77
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The HTTP face of {@link DataLock}: the routes an unlock screen talks to.
3
+ *
4
+ * Separate from `@pikku/core/classification` on purpose — that entry point is
5
+ * types and crypto, and a runtime that never serves HTTP should not have to
6
+ * load a router to use it.
7
+ */
8
+ export { wireDataLock } from './data-lock-wiring.js';
9
+ export type { DataLockStatus, DataLockWiringOptions, } from './data-lock-wiring.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The HTTP face of {@link DataLock}: the routes an unlock screen talks to.
3
+ *
4
+ * Separate from `@pikku/core/classification` on purpose — that entry point is
5
+ * types and crypto, and a runtime that never serves HTTP should not have to
6
+ * load a router to use it.
7
+ */
8
+ export { wireDataLock } from './data-lock-wiring.js';
@@ -16,9 +16,13 @@ export interface GatewayAttachment {
16
16
  export interface GatewayInboundMessage {
17
17
  /** Platform-specific: a phone number, a Slack user id, and so on. */
18
18
  senderId: string;
19
+ /** What they said, as plain text, with the provider's markup stripped. */
19
20
  text: string;
21
+ /** The provider's own event, untouched, for anything this shape drops. */
20
22
  raw: unknown;
23
+ /** Files and media that came with the message. */
21
24
  attachments?: GatewayAttachment[];
25
+ /** Anything else the adapter wants to carry through to the wiring. */
22
26
  metadata?: Record<string, unknown>;
23
27
  }
24
28
  /**
@@ -26,8 +30,11 @@ export interface GatewayInboundMessage {
26
30
  * own rich content.
27
31
  */
28
32
  export interface GatewayOutboundMessage {
33
+ /** The reply as plain text. Every provider can render this. */
29
34
  text?: string;
35
+ /** The provider's own rich payload, e.g. Slack blocks. Passed through as-is. */
30
36
  richContent?: Record<string, unknown>;
37
+ /** Files and media to send alongside. */
31
38
  attachments?: GatewayAttachment[];
32
39
  }
33
40
  /**
@@ -35,9 +42,12 @@ export interface GatewayOutboundMessage {
35
42
  * provider expects back, or not.
36
43
  */
37
44
  export type WebhookVerificationResult = {
45
+ /** True when the request really came from the provider. */
38
46
  verified: true;
47
+ /** What to echo back, e.g. Meta's hub.challenge. */
39
48
  response: unknown;
40
49
  } | {
50
+ /** False when the signature or challenge did not check out. */
41
51
  verified: false;
42
52
  };
43
53
  /**
@@ -45,12 +55,15 @@ export type WebhookVerificationResult = {
45
55
  * message, send one back, and open and close the connection.
46
56
  */
47
57
  export interface GatewayAdapter {
58
+ /** Identifies the gateway in wirings and logs, e.g. `'slack'`. */
48
59
  name: string;
49
60
  /** Return null to ignore the event, e.g. a delivery receipt. */
50
61
  parse(data: unknown): GatewayInboundMessage | null;
62
+ /** Deliver a reply back to the sender the message came from. */
51
63
  send(senderId: string, message: GatewayOutboundMessage): Promise<void>;
52
64
  /** Called by GatewayService.start(); must call onMessage per incoming event. */
53
65
  init(onMessage: (data: unknown) => Promise<void>): Promise<void>;
66
+ /** Called by GatewayService.stop(); release the connection init() opened. */
54
67
  close(): Promise<void>;
55
68
  /** Receives the GET query params, or the POST body when called from the POST handler. */
56
69
  verifyWebhook?(data: unknown, request?: PikkuHTTPRequest): WebhookVerificationResult | Promise<WebhookVerificationResult>;
@@ -18,5 +18,6 @@ export type { CorePersona, CorePersonas, PersonaAccountMeta, PersonaDefinitions,
18
18
  * Lambda deploy would load outright.
19
19
  */
20
20
  export { HttpPersona, createHttpPersonas, type HttpPersonasConfig, } from '../../services/http-personas.js';
21
- export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, type PersonaSignIn, type OperatorSignInOptions, type OperatorSessionResult, } from '../../services/persona-sign-in.js';
21
+ export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, type ActorSecretResolver, type PersonaSignIn, type OperatorSignInOptions, type OperatorSessionResult, } from '../../services/persona-sign-in.js';
22
+ export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from '../../services/persona-actor-secret.js';
22
23
  export { postScenarioJson, readScenarioHttpResponse, } from '../../services/personas-service.js';
@@ -15,4 +15,5 @@ export { APP_SCOPE_ROOT, appScopeId, buildAppScopeDefinition, } from './persona-
15
15
  */
16
16
  export { HttpPersona, createHttpPersonas, } from '../../services/http-personas.js';
17
17
  export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, } from '../../services/persona-sign-in.js';
18
+ export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from '../../services/persona-actor-secret.js';
18
19
  export { postScenarioJson, readScenarioHttpResponse, } from '../../services/personas-service.js';
@@ -1,8 +1,16 @@
1
1
  export type CoreSecret<T = unknown> = {
2
+ /** The key code reads it by: `secrets.getSecret('NAME')`. SCREAMING_SNAKE_CASE. */
2
3
  name: string;
4
+ /** How the secret is labelled wherever a person is asked to supply it. */
3
5
  displayName: string;
6
+ /** What this secret is for, shown beside the field someone has to fill in. */
4
7
  description?: string;
8
+ /** The id under the backing store, which is where the value actually lives. */
5
9
  secretId: string;
10
+ /**
11
+ * The shape of the value, as a schema. This is what types `getSecret`'s
12
+ * result — pass the schema itself, not an instance of it.
13
+ */
6
14
  schema: T;
7
15
  /** Required by default: this says absence is a supported state, and `getSecret` resolves `undefined` rather than throwing. */
8
16
  optional?: boolean;
@@ -4,6 +4,7 @@ import type { VariablesService } from '../../services/variables-service.js';
4
4
  import type { AgentRunnerService } from '../../services/agent-runner-service.js';
5
5
  import type { HttpPersonasConfig } from '../../services/http-personas.js';
6
6
  import type { ResolvedPersona, ScenarioPersonas } from '../../services/personas-service.js';
7
+ import type { PersonaEnvironment } from '../persona/persona-environments.js';
7
8
  import type { StepRecord, VirtualUserDisposition } from './virtual-user.types.js';
8
9
  import type { VirtualUserRunRecord, VirtualUserRunStore } from './virtual-user-run-store.js';
9
10
  import type { VirtualUserScheduleRecord, VirtualUserScheduleStore } from './virtual-user-schedule-store.js';
@@ -84,10 +85,15 @@ export interface StartVirtualUserRunParams {
84
85
  /**
85
86
  * The app's config, read only for `nodeEnv` — structural because an
86
87
  * application's Config is its own interface and need not declare it at all.
88
+ * The fallback signal, used only by a project that configures no environments.
87
89
  */
88
90
  config: {
89
91
  nodeEnv?: string;
90
92
  } | undefined;
93
+ /** `environments` from pikku.config.json, as generated beside the personas. */
94
+ environments?: Readonly<Record<string, PersonaEnvironment>>;
95
+ /** Which of them this process is. Defaults to `PIKKU_ENV`. */
96
+ environment?: string;
91
97
  persona: string;
92
98
  disposition?: string;
93
99
  seed?: number;
@@ -112,7 +118,7 @@ export interface StartedVirtualUserRun {
112
118
  * scheduled tick have in common. The dispatch that follows is typed off the
113
119
  * app's RPC map, so it stays in the generated wiring.
114
120
  */
115
- export declare const startVirtualUserRun: ({ store, personas, config, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }: StartVirtualUserRunParams) => Promise<StartedVirtualUserRun>;
121
+ export declare const startVirtualUserRun: ({ store, personas, config, environments, environment, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }: StartVirtualUserRunParams) => Promise<StartedVirtualUserRun>;
116
122
  /**
117
123
  * One run on the wire.
118
124
  *
@@ -85,6 +85,28 @@ export const requireVirtualUserScheduleStore = (store) => {
85
85
  }
86
86
  return store;
87
87
  };
88
+ /**
89
+ * Whether this process is running against production, for the disposition rule.
90
+ *
91
+ * The configured environment wins over `NODE_ENV` because they answer different
92
+ * questions. A deployment whose staging is a production *mirror* runs
93
+ * `NODE_ENV=production` there too — keying on it refuses every disposition on
94
+ * the one environment they exist to be used on. `PIKKU_ENV` names which of the
95
+ * configured environments this is, which is the question actually being asked,
96
+ * and it is the same signal `personaEnvironmentRefusal` already checks at
97
+ * sign-in.
98
+ *
99
+ * Unresolved is treated as production: an environment nobody can name is one
100
+ * whose data nobody can vouch for. `NODE_ENV` remains the answer only for a
101
+ * project that configures no environments at all, which has no production
102
+ * environment declared for this to be wrong about.
103
+ */
104
+ const isProductionRun = (config, environments, environment) => {
105
+ if (!environments || Object.keys(environments).length === 0) {
106
+ return config?.nodeEnv === 'production';
107
+ }
108
+ return environment ? Boolean(environments[environment]?.production) : true;
109
+ };
88
110
  /**
89
111
  * Resolves a request against the declaration and records the run.
90
112
  *
@@ -92,7 +114,7 @@ export const requireVirtualUserScheduleStore = (store) => {
92
114
  * scheduled tick have in common. The dispatch that follows is typed off the
93
115
  * app's RPC map, so it stays in the generated wiring.
94
116
  */
95
- export const startVirtualUserRun = async ({ store, personas, config, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }) => {
117
+ export const startVirtualUserRun = async ({ store, personas, config, environments, environment = process.env.PIKKU_ENV, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }) => {
96
118
  const runStore = requireVirtualUserRunStore(store);
97
119
  const persona = runnablePersona(personas, personaId);
98
120
  const disposition = (requested ??
@@ -101,8 +123,8 @@ export const startVirtualUserRun = async ({ store, personas, config, persona: pe
101
123
  // Every disposition other than this one exists to find out what the product
102
124
  // does wrong, which is not a thing to do to real customers' data. Checked
103
125
  // against the effective disposition, so an override cannot smuggle one in.
104
- if (config?.nodeEnv === 'production' &&
105
- disposition !== PRODUCTION_DISPOSITION) {
126
+ if (disposition !== PRODUCTION_DISPOSITION &&
127
+ isProductionRun(config, environments, environment)) {
106
128
  throw new Error(`Only the '${PRODUCTION_DISPOSITION}' disposition may run against production; "${personaId}" is ${disposition}`);
107
129
  }
108
130
  // Seeded here rather than inside the engine so the record carries the seed
@@ -52,7 +52,7 @@ instances:
52
52
  different one, and every finding it produces is unreproducible.
53
53
  - **A run still `running` after `STALE_RUN_AFTER_MS` is failed and the persona
54
54
  runs again.** This is where the stranded-record cost of
55
- [a virtual user run being neither a workflow nor a queued job](a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md)
55
+ [a virtual user run not being a workflow](a-virtual-user-run-is-not-a-workflow-but-it-needs-a-trigger.md)
56
56
  gets paid: without it, one restart mid-run would block that persona's schedule
57
57
  permanently.
58
58
 
@@ -0,0 +1,65 @@
1
+ ---
2
+ type: decision
3
+ title: A virtual user run is not a workflow, but it needs a trigger
4
+ description: runVirtualUser writes its record and dispatches the run onto a queue at one attempt — an exploratory run has nothing to replay, but a deployment that puts each function in its own unit has nothing to fire it either
5
+ tags: virtual-user, storage, deploy
6
+ ---
7
+
8
+ # A virtual user run is not a workflow, but it needs a trigger
9
+
10
+ `runVirtualUser` — the RPC `scaffold.virtualUser` generates — does three things
11
+ in order: writes a `VirtualUserRunStore` record, dispatches
12
+ `executeVirtualUserRun`, and returns the `runId`. The request never waits for
13
+ the run; a run takes minutes and survives neither a rollout nor a proxy timeout.
14
+
15
+ **A workflow** is still the wrong shape, for the reason it always was. Its value
16
+ is that a run can be resumed at the step it died on, and that the same input
17
+ reaches the same step. A virtual user is the opposite by construction — it is an
18
+ LLM deciding what to try next, so no two attempts take the same steps, and there
19
+ is no step to resume _to_. Recording a run as a workflow puts entries in the
20
+ workflow store that can never be replayed, and gives every operator reading that
21
+ store a row that lies about what it is. The seed makes a run _reproducible_ —
22
+ run it again and it explores the same way — which is a different property from
23
+ resumable, and one the record already carries.
24
+
25
+ **A queue was rejected once, on durability, and that was the wrong question.**
26
+ The original reasoning weighed a broker dependency against a retry nobody wants,
27
+ and concluded the in-process dispatch was enough. It is enough in one process.
28
+ It is not a dispatch at all under a deployment that puts each function in its own
29
+ unit: there is no in-process promise to leave running, and `executeVirtualUserRun`
30
+ — sessionless, unexposed, wired to nothing — is not a function any unit can be
31
+ reached at. The RPC resolves to nothing, the rejection is swallowed by the
32
+ `catch` that exists to stop it taking the process down, and the run parks at
33
+ `running` with zero steps and no error anywhere. That is what a fabric stage did.
34
+
35
+ So the queue is not bought for durability. It is bought because **a trigger is
36
+ what makes a function deployable**: `wireQueueWorker` puts `executeVirtualUserRun`
37
+ in the manifest, which gives it a unit and gives the platform somewhere to
38
+ deliver to. The job is dispatched at `attempts: 1`, because a redelivery is a
39
+ second different outing writing into a record that already has an outcome — the
40
+ retry the queue offers is precisely the part that stays unused.
41
+
42
+ A project with no queue service keeps the in-process dispatch. That is not a
43
+ fallback that hides a failure: a project without a broker runs in one process,
44
+ where an unawaited promise is a real dispatch and the only correct one.
45
+
46
+ The record remains the run's only trace, and that is what `VirtualUserRunStore`
47
+ exists for. It is also why `fail()` is a method rather than an absence: a run
48
+ that crashed and a run that found nothing are different answers, and a record
49
+ left at `running` is neither.
50
+
51
+ The cost is smaller than it was but has not gone: **a restart mid-run strands a
52
+ record at `running` with nothing left to finish it**, since nothing retries. A
53
+ run older than its budget window and still `running` is dead, not working — a
54
+ read-side rule. A stranded run is started again, with its seed if the caller
55
+ wants the same exploration.
56
+
57
+ Where that rule is actually applied is
58
+ [the schedule tick](a-virtual-user-cadence-is-a-row-not-a-timer.md), which has
59
+ to: a record stuck at `running` would otherwise block its persona's cadence
60
+ forever.
61
+
62
+ **What this rules out:** dispatching the run through `startWorkflow`; awaiting
63
+ the engine inside the request; retrying a run that failed; storing the operator
64
+ token on the record rather than on the dispatch; and inferring `status` from
65
+ `finishedAt` being unset, which cannot separate a crash from a run still going.
@@ -17,7 +17,7 @@ caller is entitled to assume.
17
17
  - [A secret that fails to decrypt fails the whole read](a-secret-that-fails-to-decrypt-fails-the-whole-read.md) — getSecrets throws naming the key and its key_version rather than omitting the row, because a silent omission surfaces as an unrelated failure much later
18
18
  - [A virtual user decides whether to trust its notes once per turn, by one roll](a-virtual-user-decides-whether-to-trust-memory-once-per-turn.md) — The difference between the stale, newcomer and auditor dispositions is expressed as a single probability rather than as prose in each prompt
19
19
  - [A virtual user cadence is a row, not a timer](a-virtual-user-cadence-is-a-row-not-a-timer.md) — how often a persona runs is stored as a due time per persona and acted on by a tick the project schedules — pikku never starts a timer, and a run never reschedules itself
20
- - [A virtual user run is not a workflow and not a queued job](a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md) — runVirtualUser writes its record, dispatches the run without awaiting it, and returns the id because an exploratory run has nothing to replay and the record already carries what a queue would be holding
20
+ - [A virtual user run is not a workflow, but it needs a trigger](a-virtual-user-run-is-not-a-workflow-but-it-needs-a-trigger.md) — runVirtualUser writes its record and dispatches the run onto a queue at one attempt — an exploratory run has nothing to replay, but a deployment that puts each function in its own unit has nothing to fire it either
21
21
  - [A wall-clock threshold is a load test in disguise](a-wall-clock-threshold-is-a-load-test-in-disguise.md) — The KEK derivation test asserted a fixed 50ms budget for work that took 10ms, which went red about one run in five once the suite was large enough to compete for the machine
22
22
  - [A workflow's wire is built from the run record, not from the RPC service](a-workflow-wire-is-built-from-the-run-not-from-the-rpc-service.md) — The RPC service exposes no wire, so every rpcService.wire read was undefined; the run record is the only thing that carries the caller across a step boundary
23
23
  - [An actor conversation starts from a seeded kickoff message](actor-flow-conversations-seed-a-hidden-kickoff-message.md) — The actor's first turn needs a non-empty message list because providers reject an empty prompt; the seed is an instruction and stays out of the transcript
@@ -1,27 +1,31 @@
1
1
  ---
2
2
  type: decision
3
3
  title: Actor sign-in only works for actor-flagged users
4
- description: The scenario actor secret mints sessions for user rows flagged actor and nothing else, so holding it never impersonates a real user
4
+ description: An actor credential mints sessions for user rows flagged actor and nothing else, so holding one never impersonates a real user
5
5
  tags: services
6
6
  ---
7
7
 
8
8
  # Actor sign-in only works for actor-flagged users
9
9
 
10
- `HttpScenarioActorsConfig.secret`
11
- (`packages/core/src/services/http-scenario-actors.ts`) is a shared impersonation
12
- secret: `HttpScenarioActor.login` POSTs `{ email, name, secret }` to
10
+ `HttpPersonasConfig.secret` (`packages/core/src/services/http-personas.ts`) is
11
+ what `ActorSignIn.login` presents: it POSTs `{ email, name, secret }` to
13
12
  `/auth/sign-in/actor` and gets back a session. That looks like a master key, and
14
- it deliberately is not one.
13
+ it deliberately is not one — for two independent reasons.
15
14
 
16
- The Better Auth actor plugin on the other end upserts and signs in only user rows
17
- flagged `actor: true`. Presenting the secret with a real customer's email does not
18
- mint that customer's session — it is refused. The `actor` flag also flows into the
19
- minted session, so audits and analytics can tell scenario traffic from human
20
- traffic after the fact. The blast radius of a leaked actor secret is therefore the
21
- synthetic actor population, not the user table.
15
+ The first is the flag. The Better Auth actor plugin on the other end upserts and
16
+ signs in only user rows flagged `actor: true`. Presenting a credential with a
17
+ real customer's email does not mint that customer's session — it is refused. The
18
+ `actor` flag also flows into the minted session, so audits and analytics can tell
19
+ scenario traffic from human traffic after the fact.
20
+
21
+ The second is that a credential is not shared. What is presented is derived from
22
+ the root `SCENARIO_ACTOR_SECRET` and the address it signs in as — see
23
+ [an actor credential is derived per persona](an-actor-credential-is-derived-per-persona.md) —
24
+ so the blast radius of a leaked credential is one synthetic account, not the
25
+ synthetic actor population.
22
26
 
23
27
  **What this rules out:** widening the sign-in endpoint to accept any email "so
24
- scenarios can test as a real user", and treating the actor secret as equivalent to
25
- a session-signing key. It also rules out dropping the `actor` flag from the minted
26
- session — the audit trail's ability to separate synthetic from real activity
27
- depends on it.
28
+ scenarios can test as a real user", and treating an actor credential as
29
+ equivalent to a session-signing key. It also rules out dropping the `actor` flag
30
+ from the minted session — the audit trail's ability to separate synthetic from
31
+ real activity depends on it.
@@ -0,0 +1,41 @@
1
+ ---
2
+ type: decision
3
+ title: An actor credential is derived per persona
4
+ description: What a caller presents to the actor endpoint is HKDF-derived from the root secret and the address it signs in as, so one credential opens one persona
5
+ tags: services
6
+ ---
7
+
8
+ # An actor credential is derived per persona
9
+
10
+ `SCENARIO_ACTOR_SECRET` is a root, not a password. What a caller presents to
11
+ `/auth/sign-in/actor` is `deriveActorSecret(root, email)`
12
+ (`packages/core/src/services/persona-actor-secret.ts`) — an HKDF-expanded
13
+ HMAC-SHA256 over the lowercased address, on the same key-material primitives
14
+ everything else in core signs with. The endpoint does not store or look anything
15
+ up: it re-derives the expected value for whichever address is being signed in as
16
+ and compares. A credential minted for one persona is refused for every other.
17
+
18
+ The root itself is not accepted as a credential, and a root shorter than 32
19
+ characters refuses the endpoint outright rather than deriving weak credentials
20
+ from it. The server-side warning names the problem; what the client is told does
21
+ not.
22
+
23
+ Derivation rather than a per-persona secrets table because there is then nothing
24
+ to store, provision, or keep in sync — the target already holds the root, and
25
+ rotating it invalidates every credential at once.
26
+
27
+ This is what lets a holder be handed less than everything:
28
+
29
+ - The browser switcher gets `VITE_DEV_ACTOR_SECRETS`, one credential per
30
+ declared persona. The root stays on the dev server, so a bundle can no longer
31
+ hold the thing that is entitled to every persona.
32
+ - A run can be given `PIKKU_PERSONA_SECRETS` (`id=secret,…`, minted with
33
+ `pikku persona secret`) instead of the root, and then it can sign in as those
34
+ personas and no others. Asking for one outside the list throws naming the
35
+ persona rather than falling back to the root.
36
+
37
+ **What this rules out:** accepting the root as a credential at the endpoint,
38
+ putting the root in any client bundle, and comparing a presented credential
39
+ against a stored one. It also rules out per-persona secrets that are generated
40
+ randomly and written down — the derivation is the reason there is nothing to
41
+ provision.
@@ -19,7 +19,8 @@ A rule about who may do what, and which way it fails when it is unsure.
19
19
  - [A workflow run is read and approved by its owner](a-workflow-run-is-read-and-approved-by-its-owner.md) — A run started through a session records that user and only that user may read it or answer its approval gates; a run with no recorded owner has no ownership to enforce
20
20
  - [An actor's missing approval decision defaults to denied](actor-flow-missing-approval-decisions-default-to-denied.md) — Every pending tool call gets an explicit decision; an id the persona LLM omitted is denied, so a dropped field can never read as consent
21
21
  - [Actor sign-in is proven by Set-Cookie, not a non-empty jar](actor-sign-in-is-proven-by-set-cookie-not-a-non-empty-jar.md) — HttpScenarioActor tracks its own signedIn flag and requires the sign-in response itself to set a cookie, because a populated jar proves nothing
22
- - [Actor sign-in only works for actor-flagged users](actor-sign-in-only-works-for-actor-flagged-users.md) — The scenario actor secret mints sessions for user rows flagged actor and nothing else, so holding it never impersonates a real user
22
+ - [Actor sign-in only works for actor-flagged users](actor-sign-in-only-works-for-actor-flagged-users.md) — An actor credential mints sessions for user rows flagged actor and nothing else, so holding one never impersonates a real user
23
+ - [An actor credential is derived per persona](an-actor-credential-is-derived-per-persona.md) — What a caller presents to the actor endpoint is HKDF-derived from the root secret and the address it signs in as, so one credential opens one persona
23
24
  - [Addon auth and tags only tighten, and resolve where the function runs](addon-auth-and-tags-only-tighten.md) — wireAddon auth and tags are applied in runPikkuFunc like scopes, but auth:false is ignored and tags resolve against the consuming app's tag groups rather than the addon package's
24
25
  - [Addon auth and tag gates apply wherever the function runs, including inside the addon](addon-config-gates-apply-only-at-the-namespaced-rpc-boundary.md) — wireAddon's auth and tags moved from the namespaced RPC boundary into runPikkuFunc, so they also apply to direct wirings and to bare intra-addon calls
25
26
  - [Addon scopes are resolved where the function runs](addon-scopes-are-resolved-where-the-function-runs.md) — wireAddon scopes are merged inside runPikkuFunc rather than at namespace resolution, because most wirings reach an addon function without ever resolving a namespace
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.97",
3
+ "version": "0.12.99",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -18,12 +18,12 @@
18
18
  },
19
19
  "sideEffects": [
20
20
  "./dist/errors/errors.js",
21
- "./dist/wirings/rpc/rpc-runner.js",
22
21
  "./dist/wirings/addon/remote-addon-auth.js",
22
+ "./dist/wirings/rpc/rpc-runner.js",
23
+ "./dist/wirings/workflow/pikku-scenario-service.js",
23
24
  "./dist/wirings/workflow/workflow-approval-policy.js",
24
25
  "./dist/wirings/workflow/workflow-errors.js",
25
- "./dist/wirings/workflow/workflow-run-ownership.js",
26
- "./dist/wirings/workflow/pikku-scenario-service.js"
26
+ "./dist/wirings/workflow/workflow-run-ownership.js"
27
27
  ],
28
28
  "exports": {
29
29
  ".": "./dist/bootstrap-compat/root.js",
@@ -54,6 +54,16 @@ export interface ColumnClassification {
54
54
  anonymize_strategy: AnonymizeStrategy
55
55
  /** At-rest representation. Absent means `plain`. */
56
56
  form?: ColumnForm
57
+ /**
58
+ * Which key protects this column, for a `wrapped` or `sealed` form. Absent
59
+ * means the deployment's default key.
60
+ *
61
+ * It is a purpose, not a tenant: naming one here says "these columns open
62
+ * together and separately from the rest", so the key that opens notes need
63
+ * not open credentials. The id is stored in the value, so a column that
64
+ * changes key is a rewrap rather than a migration.
65
+ */
66
+ keyId?: string
57
67
  description?: string
58
68
  }
59
69
 
@@ -36,3 +36,5 @@ export {
36
36
  isSecretValue,
37
37
  } from './secret-value.js'
38
38
  export type { Safe } from './secret-value.js'
39
+
40
+ export { DEFAULT_KEY_ID } from './key-ids.js'
@@ -0,0 +1,2 @@
1
+ /** The key a column protects itself with when it names none. */
2
+ export const DEFAULT_KEY_ID = 'default'
@@ -7,7 +7,6 @@ export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js'
7
7
  export { telemetryOuter, telemetryInner } from './telemetry.js'
8
8
  export {
9
9
  addTagMiddleware,
10
- addTagMiddleware as addMiddleware,
11
10
  addGlobalMiddleware,
12
11
  runMiddleware,
13
12
  } from '../middleware-runner.js'