---
name: extract-trap-task
description: >-
  Turn an approved Azure DevOps pull request into hard CentralGauge benchmark
  trap-tasks. Use when authoring new AL tasks from real merged PRs / work items,
  when a task should probe BC runtime semantics (not just syntax), or when the
  existing suite is too easy. Mines the non-obvious, comment-justified decisions
  in a PR (the traps a syntax-only model gets wrong), distills each into a
  self-contained or base-app-faithful task, and ships it ONLY after a
  discrimination probe proves a correct solution passes and a naive one fails on
  a real container.
---

# Extract Trap-Task from an ADO PR

## Core insight

In a well-reviewed PR, **every code comment that justifies a non-obvious decision
marks a latent trap.** Real examples from the seed PRs (Continia `Document Output
- Extensions`, PRs 50590 / 50285):

- "We can't use a TryFunction: TryFunctions can't contain database writes on-prem."
- "BindSubscription is bound to the current call-stack frame and does NOT reliably
  propagate into Codeunit.Run's isolated frame."
- "Gate on 'Change Log Activated' FIRST — IsAlwaysLoggedTable() returns true for
  posted sales tables regardless of the global flag."
- "Read all matching rows into a List first — you can't insert into a table while
  iterating it."

Each justification is one thing a model that only memorized AL syntax gets wrong.
Harvest those justifications; each becomes a candidate task.

## The hard gate (non-negotiable)

**Ship a task ONLY when, on a real Cronus container, a correct reference solution
PASSES its oracle and a naive-trap reference solution genuinely FAILS the same
oracle.** The naive is a throwaway dev artifact — never committed. If the test
passes for both, or fails for both, the oracle does not measure the trap: redesign
or drop. This is the entire point; do not skip it to save a container run.

## Six stages

### 1. SELECT
Pick an approved/completed PR + its linked work item. The work item gives business
*intent* ("why"); the diff gives *mechanism* ("how"). Tools:
`mcp__azureDevOps__list_pull_requests` (with `pullRequestId`) →
`mcp__azureDevOps__get_pull_request_changes` +
`mcp__azureDevOps__get_work_item`.

### 2. EXTRACT
Pull the diff + work item description. Flag every comment or decision that
justifies a non-obvious AL choice. Output a candidate list, each as: "naive model
would do X; correct is Y because Z."

### 3. DISTILL
Per trap: choose containment (see `reference/containment-policy.md`), strip
project-specific objects (no Continia objects — they aren't in the vanilla
container), restate as a generic problem. The prompt describes *what* to build,
never *how*, and carries **no guiding notes** (project rule — hints that help the
model dodge the trap destroy the signal). Specifying required *behavior* is
allowed; explaining AL mechanics or naming the mechanism (`BindSubscription`,
`Permissions`, `Codeunit.Run`, `trigger OnRun`) is not.

### 4. PREMISE-GATE (do this BEFORE full authoring — the most important lesson)
Many source-PR traps lean on version- or context-specific BC behavior and **do
not reproduce** in a vanilla container. Verify the trap actually discriminates on
THIS BC build with a minimal throwaway probe first. Confirmed non-reproducing
traps from the seed batch (BC 28.0.46665.50383):
- `IsAlwaysLoggedTable("Sales Invoice Header")` = FALSE (the always-logged set is
  permission/security tables, e.g. `User` = TRUE — retarget, don't assume).
- Indirect `Permissions = tabledata X = RIMD` is NOT honored under
  `TestPermissions = Restrictive`/`NonRestrictive` (only `Disabled`) — the
  permission trap is untestable in the AL test harness here.
- A caller-bound subscription DOES propagate into a `Codeunit.Run` frame, so the
  "bind must be inside OnRun" frame-isolation trap does not discriminate.
- `StartSession` does NOT run under the default SOAP `TestIsolation = Codeunit`;
  a background-session trap needs `RequiredTestIsolation = Disabled` on the test
  codeunit + harness runner routing (see "Test isolation" below).
- An AL **Query object's child DataItem defaults to `SqlJoinType = LeftOuterJoin`,
  NOT InnerJoin** (BC 28). A "childless parents get dropped" trap does NOT
  reproduce by omitting the property — the default already keeps them; only an
  UNMOTIVATED explicit `SqlJoinType = InnerJoin` drops them, which a no-hints
  model never writes. (Query objects DO run fine from a test codeunit via
  `.Open`/`.Read`/`.Close`.)
If the premise fails, drop or retarget — never ship a non-discriminating task.
**A scout's "STRONG" rating is not a verified premise.** Triage scouts rank
candidates from the PR diff without running them on the container; batch-4's
Sustainability picks (rated STRONG) blocked 2-of-3 on premise (a backwards
Query-join default, a forcing gap). ALWAYS premise-gate on Cronus28 before
authoring, regardless of how confident the scout was.

### 5. AUTHOR + DISCRIMINATION PROBE
Write the task YAML + oracle test + prereq app (if self-contained). At dev time
only, write a **correct** and a **naive** reference solution under `scratch/`
(gitignored). Probe both with the harness (below). Ship only on the hard gate.

### 6. VALIDATE + REGISTER
`validate-tasks` (Zod load + no-hints scan), local dry-run, then
`sync-catalog --apply` and the `refresh-task-taxonomy` skill. (Registration is
often deferred; keep local until deliberately publishing.)

## The probe harness

`scripts/trap-probe.ts` runs a solution through the real compile→publish→test path
and classifies the outcome:

```
deno run -A scripts/trap-probe.ts --task <id> --solution <dir> --expect pass|fail --container Cronus28
```

- **Three outcomes, not two:** `pass` (exit 0), `fail`/MISMATCH (exit 1),
  **`inconclusive`** (exit 3 = a thrown infra error, e.g. zero-tests-after-publish;
  just re-run). A naive must produce a GENUINE `fail` (a test-assertion failure or
  a compile error), never an `inconclusive` — otherwise the discrimination is
  spurious.
- **Cronus28 only.** Container credentials are wired only for `Cronus28`; other
  containers 401. Do not fall back to Cronus281+.

## Shallow-oracle guard (learned the hard way, twice)

A two-state oracle (empty vs full) routinely leaves a hole: a plausible wrong
implementation passes both. Examples that slipped a 2-case oracle:
- A whole-table short-circuit (`if not Target.IsEmpty() then exit(0)`) passing an
  idempotent-copy test.
- An `Activated AND explicit-config` detector passing an always-logged test where
  the active case set both conditions true.

**Add an INTERMEDIATE-state case** that only the genuinely-correct implementation
passes (a partial overlap; an active-but-not-explicitly-configured state), and
prove it with a third "wrong-C" reference that passes the other cases but fails
the new one. Defend against *plausible* naive implementations, not against
adversarial cheats (a model that directly writes the observable table it was never
told exists is not a realistic failure mode).

**Codeunit-wraps-table bypass (batch-5, X024).** When the trap lives in a TABLE
FIELD (a type/property choice) but the model exposes a `codeunit` CRUD API over
that table, asserting ONLY through the API (`Register`/`GetRef`) lets a model that
caches state in a codeunit variable / `Dictionary` (never round-tripping the
table) pass regardless of the field — bypassing the whole trap. "Add an internal
cache" is a plausible LLM habit, not an adversarial cheat, whenever there's an
API indirection layer over the trapped field. The oracle must READ THE TABLE ROW
DIRECTLY (`Rec.Get(pk); Assert.AreEqual(expected, Rec."Field")`) so the value must
actually persist through the trapped field. Prove closure with a THIRD
cache-bypass reference (Text field but codeunit-`Dictionary` storage) that now
fails the direct-table assertion.

## Marking convention

Every new task: id `CG-AL-X###` (the `X` cohort namespace, regex already allows
it), `metadata.cohort: ado-trap-2026`, `metadata.source_pr: <id>`. Legacy tasks are
`CG-AL-[EMH]*`; the cohort is glob-selectable as `tasks/**/CG-AL-X*.yml`.
Difficulty comes from the directory + `metadata.difficulty`, not the id letter.

## AL / harness gotchas (save a probe cycle)

- `domains` uses the `DomainSchema` vocabulary (`src/tasks/domains.ts`:
  `codeunits`, `events`, `error-handling`, `permissions`, ...) — a DIFFERENT list
  than the 9-group `metadata.category` taxonomy. Classify both; `domains` powers
  per-domain leaderboard scores.
- `Record.Get()` on a single-row setup table needs the PK arg: `Get('')`.
- Reserved words can't be variable names: `Key`, `User`, `Protected`, ...
- A `[TryFunction]` containing a DB write fails at **runtime** (a genuine per-test
  failure → classified `fail`, good), not at compile time.
- Prereq app UUIDs must be valid hex — use a hex-safe suffix (`0a01`, not `x001`).
- Test codeunit shape: `Subtype = Test; TestPermissions = Disabled;` and
  `Assert: Codeunit Assert` (base-app Assert, not "Library Assert").
- Object-ID bands: prereq `69000-69999`, generated/model `70000-79999`, tests
  `80000-89999`. Collision-check before assigning.

## Test isolation & shared-container hygiene (batch-2 learnings)

The oracle runs in a container REUSED across tasks and candidates. Tasks that
create persistent state must not poison later runs:

- **No `[TestCleanup]` hook exists in BC 28 AL** — only rollback-based
  `TestIsolation`/`RequiredTestIsolation`. Do teardown as **start-of-test
  self-heal**: delete-before-insert all state the test touches, so a prior
  candidate's failure (the EXPECTED outcome for a hard trap) can't leave state
  that collides.
- **`Commit()` defeats the runner's rollback.** A trap that needs `Commit`
  (`ChangeCompany` cross-company reads, `StartSession` visibility) loses the
  automatic per-test rollback, so committed rows persist on the shared container.
  Without self-heal, the NEXT candidate hits a PK collision at its `[GIVEN]` seed
  and is scored a FALSE FAILURE (not a recognized infra signature → no
  auto-reroute). Make seeding idempotent; prove it by running naive-then-correct
  consecutively (correct must still pass).
- **`StartSession` needs `RequiredTestIsolation = Disabled` + harness routing.**
  The default SOAP `TestIsolation = Codeunit` blocks it. Route opt-in codeunits
  to the platform isolation-disabled runner (`infra/cg-test-harness/` +
  `HARNESS_APP_VERSION` bump in `bc-container-provider.ts`), gated so only tasks
  declaring the property are affected. `Disabled` also removes auto-rollback →
  self-heal applies.
- **The observable must REQUIRE the mechanism.** If a trap's observable is
  trivially computable without the mechanism (e.g. "return the sum" when the trap
  is how the sum is produced across sessions), a plausible model bypasses the
  whole mechanism and passes. Make the expected value NON-derivable from the
  visible inputs (an opaque worker formula, a per-run committed factor) and
  assert proof-of-execution (the mechanism's own side effects), so passing
  requires actually running it. Verify with a third "bypass" reference.

## What the harness can't test — scouting filters (batch-3 learnings)

Some trap classes are structurally untestable in this harness. Skip them during
SELECT, or premise-gate hard:

- **External-caller / cross-module identity is untestable.** The harness bundles
  the test codeunit AND the candidate into ONE fixed app
  (`BENCHMARK_APP_ID` in `mcp/al-tools-server.ts`), so the test is never external
  to the candidate's module. Traps that depend on `GetCallerModuleInfo`, caller
  app-identity, or a genuinely external invoker (e.g. fail-open-guard bugs) have
  no external caller to observe — both correct and naive return the same wrong
  answer. Needs a companion-app-published-after-candidate mechanism the harness
  doesn't have. Skip these.
- **Mark/MarkedOnly and page-navigation-scope traps resist discrimination.** A
  temp-record `Page.Run(0, TempItem)` (a `temporary` record backs a page fine) is
  an idiomatic AL bypass that navigates the same set without `Mark`/`MarkedOnly` —
  so the observable doesn't force the mechanism. Forcing it needs a write-back /
  persistence oracle (edit via the page, verify the REAL table changed) on the
  legacy TestPage path, which is fragile. Usually not worth it.
- **Refactoring-shape bugs are hard to force in fresh code-gen.** A trap that only
  manifests from a DRY refactor (e.g. hoisting a call one stack frame too deep) is
  rarely reproduced by a model authoring fresh code — the natural single-procedure
  solution is correct. If the only failing variant is an unnatural structure a
  real model wouldn't write, the task doesn't discriminate a plausible model.
- **Install→upgrade lifecycle is untestable.** `BcContainerProvider.prepareCandidateApp`
  uninstalls + unpublishes any prior same-name app before every publish, then
  publishes fresh with `-install`. `OnInstall` fires every task but `OnUpgrade`'s
  precondition (a pre-existing older installed version) never exists — so
  upgrade-cycle traps (Upgrade Tags re-running a migration, data-upgrade
  clobbering) cannot be triggered. The `Codeunit "Upgrade Tag"` API is callable,
  but a tag-guarded idempotent migration is just the idempotency lesson —
  already covered by shipped `H039`/`H040`/`X004`.

**Proactive anti-cheat pays off.** Apply the opaque-value + proof-of-execution
guard UP FRONT (not just after a review catches it): make the expected value
non-derivable from the visible inputs (an opaque computed formula) and assert the
mechanism's own side effects. Doing this from the first draft kept every
self-contained task in the third batch clean on first review.

## Making a task frontier-HARD (the dual-frontier finding)

**Passing the discrimination probe does NOT mean the task catches a frontier
model.** The probe only proves the task beats the naive YOU wrote. Frontier models
(Opus/Fable-class) have every well-known AL gotcha memorized, so a SINGLE obscure
semantic — however clever — they solve first-try. Proven on a dual-frontier bench
(Opus 4.8 + Fable 5): `X033` (TransferFields matches by number) and `X034` (enum
`Format` returns caption not name) were BOTH aced 100% first-try by BOTH models —
as were all 22 medium X-tasks. A one-gotcha task ranks weaker models; it does not
move a frontier.

**The ONLY pattern that catches a frontier is COMPOSITIONAL / fix-has-its-own-trap:**
several interacting traps where the naive fails AND each obvious fix a frontier
reaches for ALSO fails, for a DIFFERENT reason — so only the deep-correct sequence
passes. Proven catchers:
- **X035 "poisoned rescue"** — insert a row → run a black-box engine
  error-tolerantly. TryFunction → write blocked; `if Codeunit.Run` without
  `Commit` → write-transaction error; Run-first → engine needs the row; only
  insert→`Commit`→`if Codeunit.Run` passes. **Failed Fable 0/2, caught Opus on
  attempt 1.**
- **X037 "inner commit"** — a black-box engine `Commit`s internally before
  erroring, so `if Codeunit.Run` returning false does NOT mean rollback cleaned
  up; the model must run a FILTERED compensating purge (not a blanket `DeleteAll`,
  which wipes a decoy row). **Failed Opus BOTH attempts (0/2), Fable attempt-1.**
- **X019 "stale relocate"** — a hidden helper renames the PK (an innocuous verb
  hides the mutation) AND the obvious re-read (`Find('=')`/`Get`) uses the stale
  key. Caught Opus both attempts.

**Premise-fragility of compositional traps:** the deepest layers often don't
reproduce on the target build — the phantom-buffer premise (X036) collapsed
(`Codeunit.Run(id, Rec)` copies the record out only on SUCCESS), and a mid-loop
re-key hazard (X038) HUNG the SOAP runner (infinite revisit → inconclusive, not a
clean fail → dropped: a naive must fail, never hang/timeout). Budget ~3
compositional candidates per shippable frontier-catcher, and hard-gate every
layer on-container before authoring.

Recipe (Fable's meta-lesson, confirmed on the bench): a frontier-hard task needs
BOTH (a) an innocuous verb HIDING a state mutation, AND (b) a boobytrapped
first-instinct fix. **To verify hardness you MUST dual-frontier bench** (Opus + a
second frontier via `run-xbench.ps1 -Model "...,..."`) — the probe is necessary
but not sufficient. Single-gotcha tasks are still worth shipping for field
coverage; only compositional ones make a frontier fail.

### Repair-resistance tiers (round-3 finding, benched)

Attempt-1 catch and attempt-2 catch are DIFFERENT properties. Round-3 tasks in
new territory (X045 validate-order, X046 SystemId+staleness, X047 dimension
sets) each caught a frontier on attempt-1 (X046 caught BOTH) — but ALL were
repaired by attempt-2, because their failure messages are LEGIBLE (a SystemId
mismatch or wrong tier value points straight at the mistake). The
Commit/transaction family (X037/X040/X041) resisted BOTH attempts: its failure
signatures don't reveal where the trap is, and the obvious repair has its own
trap. So:

- **Attempt-2-resistant (hardest tier):** make the failure signature OPAQUE —
  wrong values with no hint of the mechanism, and boobytrap the natural repair.
  Transaction-boundary semantics do this naturally.
- **Attempt-1 discriminators:** still valuable — the leaderboard headline is
  AUC@2 (second-attempt-only solve scores 0.5), so these genuinely de-saturate
  the metric even though pass@2 stays 100%.
- When designing for the hardest tier, ask: "does the test failure OUTPUT leak
  the fix?" If the assert message names the field/mechanism that went wrong, a
  frontier will repair it on attempt 2.

**Round-4 amendment (benched, honest): signature engineering is NOT the lever.**
Round 4 built deliberate opacity (contract-only assert messages, digest oracles)
and even byte-identical failure collisions across wrong shapes incl. the
simulated attempt-2 repair (X050, X051) — and a controlled A/B (X052 = X045's
exact trap with the oracle collapsed to one opaque Total). Result: the A/B came
out IDENTICAL to legible X045 (Opus solved both first-try), and collisions did
not stop attempt-2 repairs. Models don't gradient-follow failure output — they
re-derive from scratch on attempt 2, so if the model KNOWS the domain, any
failure signal at all triggers a successful rewrite. The Commit-family's
attempt-2 resistance comes from KNOWLEDGE-GAP DEPTH (the entire repair space is
semantically trapped — every alternative the model can think of also fails),
not from what the failure output says. Design consequence: to resist attempt 2,
make the CORRECT solution require knowledge the model lacks, with all
plausible-looking alternatives wrong — opacity/digest oracles are fine hygiene
but buy no measurable resistance on their own.

**Bench-noise caveat:** "Model returned empty response" scores as a failed
attempt-1 and pollutes PR1/AUC@2 (observed 3/6 Fable attempts in one run).
Before reading a matrix, check failureReasons — empty-response attempts carry
zero trap signal.

## Files

| Path | Role |
|---|---|
| `scripts/trap-probe.ts` | The discrimination-probe driver (pass/fail/inconclusive) |
| `reference/containment-policy.md` | Self-contained vs base-app-faithful decision |
| `docs/superpowers/specs/2026-06-30-ado-pr-trap-tasks-design.md` | The originating design spec |
| `.claude/commands/create-task.md` | Task-authoring conventions (IDs, no-hints) |
| `.claude/commands/validate-tasks.md` | Guidance + schema validation pass |
