Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions openspec/changes/learning-promotion/apply-progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,11 @@ TDD: `test/promotion-authority-coverage.test.ts` (Unit) — RED ✅ **10F** on m
Focused: `pnpm exec vitest run packages/business-domain/test/promotion-authority-coverage.test.ts` — RED 10F (main-base) → GREEN **10/10** (W3B1+W3B2 tree). Runtime harness: N/A (pure fake). Warnings: 0 new; 9 pre-existing.
Rollback: drop the coverage test file + revert the two tasks.md checkboxes + this section (no production files touched in this child).
Line count (vs 7003f50): coverage test +222, tasks.md 2/−2, this section +7 = **231 ≤ 400**.

# Work unit 3 children W3C1–W3D2 (app seam + verify hook, stacked on W3A/B1/B2)
> Stacked-to-main; strict TDD; hybrid. Each child's diff vs its predecessor: W3C1 seam 397 (evaluate.ts 184 + core test 3/3), W3C2 gates 158 (3/3), W3C3 typed matrix 341 (9/9 incl. authority never-promote matrix, skill guards, malformed/zero/conflict/foreign isolation), W3D1 verify hook 394 (verify.ts 116 + 3/3: atomic win/rollback-proof/invalid-state), W3D2 hook coverage 223 (3/3: revocation supersede, delegation-missing, version-conflict zero-writes).
TDD: W3C1 RED ✅ 1 file failed/no tests (module missing) → GREEN 3/3; W3C2/W3C3 characterization over the shipped seam (2C precedent) → GREEN 3/3 + 9/9; W3D1 RED ✅ 1 file failed/no tests → GREEN 3/3; W3D2 GREEN 3/3. Safety net: baseline 1532/6 → final **1582/6**.
Gate (task 2.5): `pnpm check` exit 0 — format ✓ typecheck ✓ build ✓ lint ✓ (9 pre-existing warnings) — test **1582 passed / 6 skipped**.
Deviations (Engram `sdd/learning-promotion/app-seam` + `sdd/learning-promotion/verify-hook`): seam deps = {events, skills, authority, trusted{principalId,actorId}} (design listed only {events,skills}; repository authority resolution requires the extra ports); verify-hook transition identity = (workId, 1) canonical (design left identity free); work-unit 3 split into EIGHT children (W3A/W3B1/W3B2/W3C1/W3C2/W3C3/W3D1/W3D2) because the named sub-boundaries "fakes" and "learning/" each also crossed 400 at apply — no size:exception.
Runtime harness: N/A — app-layer seam + hook over pure in-memory fakes; no PG/daemon/runtime boundary in this work unit.
Rollback: per child — drop the child's files + revert its checkbox/section; all files are new modules with no prior consumers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the existing worker verifier during rollback

The W3D1 rollback cannot safely “drop the child's files”: packages/app/src/worker/verify.ts already contained verifyEffect before W3D1 and is imported by worker.ts, so deleting that file would remove established worker verification behavior and break its consumers. Document a rollback that reverts only the learning-promotion hook additions and its new tests rather than claiming every affected file is a new, unused module.

Useful? React with 👍 / 👎.

2 changes: 1 addition & 1 deletion openspec/changes/learning-promotion/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Chain strategy: stacked-to-main
- [x] 2.3 GREEN `ports/fakes.ts`: in-memory candidate + authority repos (append-only, current-leaf, revocation); INSERT-only; export. (W3A candidate fake + W3B1 authority fake; W3B2 coverage tests.)
- [x] 2.4 RED fake tests: candidate replay/collision/stale/concurrency; authority current/superseded/revoked. (W3A 11/11 candidate; W3B1 8/8 core + W3B2 10/10 coverage.)
- [ ] 2.6 RED+GREEN atomic verifyWork+proof owner (`app/src/worker/verify.ts`): one transaction = `completed→verified` win + proof append (verifier, fresh Delegation, policy); revocation supersede; no partial write.
- [ ] 2.5 `pnpm check` green.
- [x] 2.5 `pnpm check` green. (1582 passed / 6 skipped on the full work-unit-3 stack; 9 pre-existing warnings.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark the completed verify-hook task as done

When this slice lands, the new coverage and apply-progress section declare W3D2 and task 2.6 complete, but the canonical task immediately above remains unchecked. Any tooling or maintainer using tasks.md will therefore continue to treat work unit 3 as incomplete; check off task 2.6 together with this completed full-suite gate.

Useful? React with 👍 / 👎.


## Phase 3: PostgreSQL

Expand Down
221 changes: 221 additions & 0 deletions packages/app/test/worker-verify-work-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import {
InMemoryDelegationRepository,
InMemoryPromotionAuthorityRepository,
InMemoryWorkRepository,
promotionScopeFor,
type FencingDirective,
type LearningSubject,
type Work,
type WorkRepository,
} from '@io/business-domain/src/index.js';
import type { DbConnection } from '@io/database/src/connection.js';
import { describe, expect, it } from 'vitest';

import { verifyWorkWithProofAtomically } from '../src/worker/verify.js';

/**
* Worker verify hook — COVERAGE (task 2.6, W3D2): revocation supersedes the
* appended proof; a missing delegation and a CAS version-conflict abort with
* ZERO writes (the win + proof rollback paths live in `worker-verify-work.test.ts`).
*/

const subject: LearningSubject = { skillId: 'sdlc-review', skillVersion: 3 };
const scope = promotionScopeFor('acme', subject);
const policyRef = { policyId: 'pol-1', version: 1 };

function completedWork(): Work {
return {
workId: 'work-1',
companyId: 'acme',
delegationId: 'del-1',
proposer: 'principal-proposer',
description: 'promote the learning candidate',
state: 'completed',
version: 3,
fencingToken: 0,
evidenceRefs: [],
};
}

/** Work fake whose CAS always loses (version-conflict), never mutating. */
class RacedWorkRepository implements WorkRepository {
private entries = new Map<string, Work>();

save(work: Work): Promise<Readonly<Work>> {
this.entries.set(work.workId, work);
return Promise.resolve(work);
}

get(companyId: string, workId: string): Promise<Work | undefined> {
const entry = this.entries.get(workId);
return Promise.resolve(
entry !== undefined && entry.companyId === companyId ? entry : undefined,
);
}

updateIfVersion(_work: Work, _expectedVersion: number, _fencing?: FencingDirective) {
return Promise.resolve({
ok: false as const,
reason: 'version-conflict' as const,
current: completedWork(),
Comment on lines +56 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the CAS loser expose the competing write

In the version-conflict scenario, the fake returns completedWork(), which is indistinguishable from the snapshot read before the CAS, and it never installs a competing winner in its store. The test therefore still passes if the hook incorrectly returns the stale pre-race work instead of cas.current, and its final state assertion cannot verify that the losing attempt preserves the winner's mutation. Return and store an observably different competing version/state so this race coverage exercises both behaviors.

Useful? React with 👍 / 👎.

});
}

listActionableByCompany(): Promise<readonly Work[]> {
return Promise.resolve([...this.entries.values()]);
}

setRecoveryRequest(): ReturnType<WorkRepository['setRecoveryRequest']> {
return Promise.resolve({ ok: false, reason: 'version-conflict' });
}

listRecoveryRequestedByCompany(): Promise<readonly Work[]> {
return Promise.resolve([]);
}
}

/** A pass-through one-shot transaction double (no repository rollback here). */
function connection(): DbConnection {
return {
execute: () => Promise.resolve(undefined),
query: <T>() => Promise.resolve([] as readonly T[]),
transaction: <T>(fn: (conn: DbConnection) => Promise<T>) =>
fn({
execute: () => Promise.resolve(undefined),
query: <U>() => Promise.resolve([] as readonly U[]),
transaction: () => Promise.reject(new Error('nested transactions are forbidden')),
}),
};
}

async function activeDelegation(): Promise<InMemoryDelegationRepository> {
const delegation = new InMemoryDelegationRepository();
await delegation.save({
delegationId: 'del-1',
companyId: 'acme',
delegator: 'owner-1',
delegate: 'principal-promoter',
authorityScope: { scope, actions: ['learning.promote'] },
budget: { currency: 'usd', limit: 1000 },
validFrom: 0,
validUntil: 99999,
expectedOutcome: 'promote the skill',
state: 'active',
});
return delegation;
}

function input() {
return { companyId: 'acme', workId: 'work-1', subject, policyRef };
}

describe('verifyWorkWithProofAtomically — coverage', () => {
it('a revoked superseding revision makes the appended proof resolve revoked', async () => {
const work = new InMemoryWorkRepository();
await work.save(completedWork());
const delegation = await activeDelegation();
const authority = new InMemoryPromotionAuthorityRepository({ delegation });
const result = await verifyWorkWithProofAtomically(
{
repositories: () => ({ work, delegation, authority }),
connection: connection(),
verifier: 'actor-verifier',
now: () => 500,
},
input(),
);
if (!result.ok) throw new Error('fixture: expected the verify win');
const { current: _current, ...rest } = result.proof;
expect(
await authority.appendProof({
...rest,
proofRevision: 2,
supersedesProofRevision: 1,
revoked: true,
revocationVersion: 1,
}),
).toBe('appended');
expect(
await authority.resolve({
sourceRef: 'proof:work-1',
companyId: 'acme',
subject,
policyRef,
at: 500,
expectedPrincipalId: 'principal-promoter',
expectedActorId: 'actor-verifier',
command: 'learning.promote',
capability: 'learning.promote',
scope,
}),
).toEqual({ kind: 'unavailable', reason: 'authority-revoked' });
});

it('a missing delegation aborts with zero writes', async () => {
const work = new InMemoryWorkRepository();
await work.save(completedWork());
const delegation = new InMemoryDelegationRepository();
const authority = new InMemoryPromotionAuthorityRepository({ delegation });
const result = await verifyWorkWithProofAtomically(
{
repositories: () => ({ work, delegation, authority }),
connection: connection(),
verifier: 'actor-verifier',
now: () => 500,
},
input(),
);
expect(result).toEqual({ ok: false, reason: 'delegation-unavailable' });
expect((await work.get('acme', 'work-1'))?.state).toBe('completed');
expect(
await authority.resolve({
sourceRef: 'proof:work-1',
companyId: 'acme',
subject,
policyRef,
at: 500,
expectedPrincipalId: 'principal-promoter',
expectedActorId: 'actor-verifier',
command: 'learning.promote',
capability: 'learning.promote',
scope,
}),
).toEqual({ kind: 'unavailable', reason: 'authority-missing' });
});

it('a version-conflict CAS loss is typed and never appends a proof', async () => {
const raced = new RacedWorkRepository();
await raced.save(completedWork());
const delegation = await activeDelegation();
const authority = new InMemoryPromotionAuthorityRepository({ delegation });
const result = await verifyWorkWithProofAtomically(
{
repositories: () => ({ work: raced, delegation, authority }),
connection: connection(),
verifier: 'actor-verifier',
now: () => 500,
},
input(),
);
expect(result).toEqual({
ok: false,
reason: 'version-conflict',
current: completedWork(),
});
expect((await raced.get('acme', 'work-1'))?.state).toBe('completed');
expect(
await authority.resolve({
sourceRef: 'proof:work-1',
companyId: 'acme',
subject,
policyRef,
at: 500,
expectedPrincipalId: 'principal-promoter',
expectedActorId: 'actor-verifier',
command: 'learning.promote',
capability: 'learning.promote',
scope,
}),
).toEqual({ kind: 'unavailable', reason: 'authority-missing' });
});
});
Loading