Skip to content

Document the human-approved clear path in the policy skills - #40

Merged
pareilly merged 15 commits into
mainfrom
d2-1658-document-clearing
Sep 10, 2026
Merged

Document the human-approved clear path in the policy skills#40
pareilly merged 15 commits into
mainfrom
d2-1658-document-clearing

Conversation

@pareilly

@pareilly pareilly commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Ready to merge once the production deploy lands. The product-repo change this
tracks is merged and production is deploying now. Both vendored files are re-paired
against the current product-repo main (not the older revision this PR first
vendored) — bundle 91572745…12ede, artifact 22f5fa77…b068, the bundle's sha
matching the product repo's committed sidecar byte for byte.

What this changes

The marker guidance in the policy skills said a marker's TTL is the only way out of a block. It is not — a person can approve a clear in a browser — and the gap ran in the direction that hurts most: an author following it wrote deny reasons telling people to wait an hour for something they could resolve in under a minute. Clearing appeared nowhere in either policy skill, so this is mostly an addition rather than a correction sweep.

dtwo-gateway-config covered the gateway.session_control block, but from a schema artifact old enough to document client_id as required and to know nothing of redirect_uri — so it told every reader to register an IdP application and paste an id, which is the one thing a gateway on Dtwo authentication should not do. That skill's coverage is generated, so the artifact is re-vendored and the digest regenerated rather than hand-edited.

dtwo-policy-rego

  • The worked example's deny reason offers the clear as the fast exit and keeps the TTL as the fallback.
  • The deny-reason guidance states that rule, and says to phrase it so the sentence still reads correctly where clearing is not armed — there the request returns a readable "not configured" refusal and the TTL half still holds. It also says to word the clear as a recovery the person authorizes, never as a way around the decision.
  • A policy may decide on the session intent but must never return it: no interpolating current_intent(input) or its caller-supplied description into a reason or a transform.
  • New Reading the registry section: data.dtwo.intent_registry is OPA base data in every policy bundle, with a worked read that sources a deny message from the registry instead of a pasted string, and a default fallback because an unregistered key would otherwise leave the whole reason undefined.

dtwo-gateway-policy

  • New Clearing a marker section, alongside the existing "how a marker gets set" material: an agent can request a clear and cannot complete one; the tools take no arguments, so it cannot choose the target; markers and intent clear separately with different scopes; the approver must be the same identity as the caller; and arming is per gateway, so the refusal has to be handled rather than assumed away.
  • That section says why the person is in the loop, not only that they are: a marker is worth exactly as much as the agent's inability to remove it, so the human step is the control rather than a tax on the flow, and what the ceremony produces is evidence that a named person, freshly authenticated, authorized this clear of these values. With the rule that follows for an agent — never offer a clear as a way around a correct block; the person judges whether the state has outlived its purpose, and every request is recorded either way.
  • The stale "no clear tool" / "lifts only on TTL expiry" claims are corrected in the design-limits summary, the marker constraints list, and the intent_marker_incompatible recovery note.
  • The short-TTL testing guidance stays — it is about iterating quickly — but now reads as a tip rather than the only exit, and approving a clear is offered as a third way to reach a clean negative test.
  • Arming guidance matches the config skill: write clearing: {enabled: true} explicitly, since an implicit block arms only while intent capture is on and parks inert otherwise — the one shape that leaves marker policies enforcing with no targeted way out of a marker.
  • New The registry as policy data section documenting the data.dtwo.intent_registry shape, flagged as not gated: the document ships to every gateway and always carries markers[], whether or not intent capture is enabled.

dtwo-gateway-config — regenerated, not hand-edited

The digest now carries, from the artifact:

  • client_id — required yesno, with guidance to leave it unset under Dtwo authentication, where the platform provisions the ceremony application per organization and the deploy renders its id.
  • redirect_uri — new field, with the derivation it overrides and the root-path constraint.
  • clearing.enabled — write true whenever you want clearing, rather than leaving the block's meaning to gateway.intent.enabled.
  • Two new cross-field constraints: a non-Dtwo IdP must set client_id (the deploy fails rather than arming a ceremony with no application), and redirect_uri replaces the callback otherwise derived from the first jwt_audience entry.
  • Six newly reserved env-var names, SESSION_CONTROL_REDIRECT_URI among them.
  • The CGNAT / 100.64.0.0/10 note on gateway.ssrf.allowed_networks, and both new oauth_config fields.

Vendored files and pins

Both vendored files come from one source revision. Refreshing the artifact alone would reintroduce the drift defect closed in #34redirect_uri documented but strict-rejected by an older bundle, SESSION_CONTROL_REDIRECT_URI reserved in the artifact but accepted inside gateway.advanced. All three pins move together: artifact sha, bundle sha, and bundle version 2.0.03.0.0.

That version bump is behavioural rather than structural. It adds two must_validate rejections, both on oauth_config: an unimplemented token_endpoint_auth_method (RFC 7591's none included, since the gateway ignores it and the key used to be stripped silently), and client_secret_basic without a client_secret. No fixture or skill file exercises either key, so no rubric semantics move, and the .d.mts shim is unchanged, so the harness's shape assumptions still hold.

Delta against the previously vendored bundle — wider than the version bumps, which count only what the product repo chose to version. session_control.redirect_uri and a session_control block with no client_id are newly accepted; the old bundle rejected both, so the rubric would have scored the shape this skill recommends as invalid. OAuth scopes is now optional ([] default) — the digest previously told readers it was required, which would have sent a scope to providers that reject one. Six env-var names are newly rejected inside gateway.advanced (CPEX_CONTROL_TELEMETRY_DB_ENABLED, CPEX_CONTROL_TELEMETRY_ENABLED, CSRF_ENABLED, MCPGATEWAY_A2A_ENABLED, SESSION_CONTROL_REDIRECT_URI, SOTW_DELETE_POLICY), as is a singular scope key on an oauth block with no scopes. token_endpoint_auth_method / omit_resource now survive a round-trip instead of being stripped. Probed against both bundles directly; every newly-rejected shape was already rejected before, for a different reason, so no rubric result moves.

scripts/generate-schema-digest.mjs renders the two new oauth_config fields. Its field order is an ordered allowlist, so a field added upstream is dropped rather than appended and the script's own coverage check refuses to write the digest until it is placed deliberately — that guardrail is now commented, because hitting it reads like a generator bug.

Verification

  • Both Rego snippets touched pass opa check, and the registry example was evaluated with opa eval against a fixture registry and session state, including the missing-entry case the default fallback exists for.
  • generate-schema-digest.mjs --check reports the digest in sync with the artifact.
  • The vendored bundle's sha256 matches the product repo's committed sidecar byte for byte.
  • skill-harness: pnpm test — 177/177 passing against the new bundle (validator bundle 4.0.0).
  • dtwo/.claude-plugin/plugin.json bumped to 1.1.8, since distributed skill content changed.

pareilly and others added 4 commits September 3, 2026 14:50
The marker guidance told authors that a marker's TTL is the only way out of
a block, so a policy author following it wrote deny reasons telling people
to wait an hour for something they could resolve in under a minute. The
gateway-config skill already documented `gateway.session_control`, so the
three skills disagreed with each other.

- `dtwo-policy-rego`: the worked example's deny reason now offers the clear
  first and keeps the TTL as the fallback, and the deny-reason guidance
  states that rule (phrased so the sentence still reads correctly on a
  gateway where clearing is not armed).
- `dtwo-gateway-policy`: new "Clearing a marker" section covering what the
  agent can and cannot do — it requests, a person approves in a browser —
  plus the argument-less tools, the separate marker and intent scopes,
  per-gateway arming and its refusal, and same-identity approval. The
  stale "no clear tool" claims are corrected, and the short-TTL testing
  guidance now reads as an iteration tip rather than the only exit.
- Both skills: a policy may decide on the session intent but must never
  return it to the agent.
- `dtwo-gateway-policy`: document `data.dtwo.intent_registry`, with a
  worked Rego read in `dtwo-policy-rego` (validated with `opa eval`,
  including the undefined-lookup case).

`dtwo-gateway-config`'s coverage is generated from the schema artifact and
needed no change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The clearing section described the shape — an agent requests, a person
approves — and every mechanical constraint that follows from it, but never
the reason, which left the constraints reading as friction rather than as
the point. The behavioural guidance was worse: it framed asking first as
etiquette rather than as the boundary it is.

Now stated plainly: a marker is worth exactly as much as the agent's
inability to remove it, so the human step is the control, and what the
ceremony produces is evidence that a named person, freshly authenticated,
authorized this clear of these specific values. Plus the rule that follows
for an agent — never offer a clear as a way around a correct block; the
person judges whether the state has outlived its purpose, and every
request is recorded either way.

The same reasoning now travels with the two shorter statements a reader
may hit on their own: the marker-constraints bullet and the deny-reason
guidance in dtwo-policy-rego (word it as a recovery the person authorizes,
not as a bypass).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- aliases is omitted when the entry has none; the omitted-when-unrestricted
  rule belongs to the transitions fields only.
- The registry-as-policy-data section sits after the gated intent material,
  so it now says up front that it is not itself gated.
- The markers intro still implied TTL was a marker's only end of life.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vendored schema artifact predated the session-control changes in the
product repo, so the config skill documented `client_id` as required and
knew nothing of `redirect_uri`. On a gateway that authenticates against
Dtwo's own IdP that is backwards: the platform provisions the ceremony
application per organization and the deploy renders its client id, so the
field should be left unset, and only a gateway trusting a customer-run IdP
needs to set one.

Both vendored files are refreshed from the same product-repo revision, per
the resolution of the bundle/artifact drift defect — refreshing the artifact
alone would have reintroduced it, with `session_control.redirect_uri`
strict-rejected by the older bundle and `SESSION_CONTROL_REDIRECT_URI`
accepted inside `gateway.advanced`.

- schema artifact and validator bundle re-vendored together; all three pins
  bumped (artifact sha, bundle sha, bundle version 2.0.0 -> 3.0.0). The
  bundle's type surface is unchanged, so the harness's shape assumptions
  still hold.
- digest regenerated: `client_id` now optional with the Dtwo-authentication
  guidance, `redirect_uri` documented, `SESSION_CONTROL_REDIRECT_URI` listed
  as reserved, and two new cross-field constraints.
- `generate-schema-digest.mjs` renders two upstream OAuth fields the artifact
  gained, `token_endpoint_auth_method` and `omit_resource`. Both are
  user-audience, so the coverage gate failed until they were added.
- incidental catch-up carried by the refresh: the CGNAT note on
  `allowed_networks`, and five newly platform-managed reserved keys.

Verified: digest --check in sync, 177 harness tests pass, biome clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pareilly and others added 4 commits September 3, 2026 17:01
…lback

Two review findings against the gateway's own state.

The federated name is hyphenated throughout — the gateway's tool table has
`dtwo-platform-intent-clear-markers`, and only `original_name` keeps the
underscore. The skill quoted the underscored form, which resolves to no tool.

The worked registry example built `reason` from a registry lookup with no
`default`, which is exactly the trap the bullet below it warns about: on an
unregistered key the lookup is undefined, so the whole `reason` is undefined
and the call denies with no message. Confirmed on OPA 1.18 — the miss case
returned `allow: false` with `reason` absent. Examples get copied more
readily than the prose under them is read, so the `default` belongs in the
snippet; the bullet now explains the line rather than asking for one that
was not there.

Verified: both branches of the example re-evaluated from the SKILL.md source
(registry hit and miss now both produce a reason), opa check clean, 177
harness tests pass, digest in sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Confirm before requesting" bullet told the reader to ask first "the
same way you would before `set_intent`". That bullet lives in Managing
Markers, which the skill is emphatic is available on every gateway, whereas
`set_intent` is not: a markers-only deployment arms clearing with
`clearing.enabled: true` and intent capture off, so the analogy points at a
tool that reader does not have. Compare against any state-changing call
instead.

The neighbouring mentions stay — they name the platform tool surface and
draw the markers-vs-intent distinction, which is orientation rather than
presenting the intent workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The product repo changed what it recommends for the `session_control` block.
An empty block means different things depending on `gateway.intent.enabled` —
it arms clearing with capture on, and parks inert with capture off — so the
guidance now prefers writing `clearing: {enabled: true}` rather than leaving
the block empty and deferring its meaning to another block. An empty block
stays fully supported and arms exactly as before; upstream also gained an
advisory when a block is present but arms nothing.

Artifact and validator bundle re-vendored together from the same revision, as
before. Only the two SHA pins move — the bundle stays at 3.0.0, since this is
metadata rather than a validation change. The digest diff is limited to the
two guidance strings and the embedded artifact sha; no other field moved.

Verified: digest --check in sync, 177 harness tests pass, biome clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The re-vendored digest now carries d2's rewritten guidance — write
clearing.enabled: true whenever you want clearing, rather than leaving it to
gateway.intent.enabled — but the clearing section still described a bare
block as sufficient. An implicit block arms only while intent capture is on
and parks inert otherwise, which is exactly the shape that leaves marker
policies enforcing with no targeted way out of a marker.

Also documents the oauth field-order allowlist in the digest generator: a
field added upstream is dropped rather than appended, and the script's own
coverage check is what makes that safe by refusing to write until the field
is placed. Worth a comment, since hitting that error looks like a generator
bug rather than the guardrail it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pareilly
pareilly marked this pull request as draft September 4, 2026 13:37
@pareilly
pareilly marked this pull request as ready for review September 4, 2026 13:42
@pareilly

pareilly commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Ready for review — but please do not merge on approval.

This vendors a schema-reference.json and validator bundle whose source revision is not yet released to production. Plugin content is customer-visible as soon as it lands on main, so merging early would document a config field and guidance that customers cannot use yet.

Review away — the hold is only on the merge button. I'll follow up here once the source revision is out, and that comment is the green light.

@oschaaf oschaaf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Led a panel review on this alongside dtwoai/d2#2036: one reviewer checked every behavioural claim in the two policy skills against the gateway source and the product repo, one checked vendoring/pins/harness/public hygiene, and I re-verified each finding before posting. Approving. Honouring the hold — merge is yours, and the button stays untouched until your green-light comment.

Verified true (customer-facing claims, against the pinned gateway release)

  • Registry document: shape, field names, FQID-only keys, transitions_* omitted-when-unrestricted / []-when-locked, always fully shaped even for an empty tenant, shipped unconditionally in the same bundle as the Rego, mounted at data.dtwo.intent_registry. The platform's own intent enforcement reads the same document.
  • Clear tools: argument-less, hyphenated wire name (the slugifier maps _-), forced fresh login (prompt=login + max_age), same-identity check, one-login-one-clear, TTL-filtered enumeration, multi-holder flag with "clearing only this one leaves it active", clear_intent offers only the trusted-writer intent, marker/intent clears are disjoint by construction, all-or-nothing commit with rollback on drift, three audit events at mint/authorize/commit.
  • Arming semantics match the validator exactly.
  • data.dtwo.lib.intent_match ships unconditionally; current_intent / current_category are real rule names.
  • Both Rego snippets pass opa check (incl. --strict, --v0-compatible, --v0-v1) and the deploy-time validator. The registry example evaluates with registry text when the entry exists and with the default when it doesn't; removing the default leaves reason undefined while allow stays false — so the "load-bearing" claim is literally true.
  • Vendoring: bundle rebuilt from the d2 PR head → 971e2d7c…ee16, byte-identical to the vendored file; schema artifact byte-identical; .d.mts unchanged; VALIDATOR_BUNDLE_VERSION 3.0.0 in both; digest --check in sync and regen is a no-op; the allowlist coverage gate really does refuse to write when a field is missing; harness 177/177; reserved-key counts 21+11+28=60 with six additions and no removals; plugin.json-only bump is the repo convention. Hygiene grep of the artifact, digest and bundle is clean.

Asks (inline)

  1. "Handle the 'not configured' refusal" needs a second branch. The reserved server hosting clear_markers is federated only when intent capture is on or clearing is armed. On the markers-only gateway with no/parked block — the very shape that bullet warns about — there is no clear tool in the list at all, so no request and no refusal. The agent has to handle tool absent too.
  2. The deny-reason wording "ask for a session clear and approve it in your browser" presumes the reader is driving an assistant that has the platform tool and relays the link. That's the design (no human-initiated path exists), but it's worth stating once so authors know who "ask" is addressed to.

Nits (inline): "every request is recorded" is true once armed (the unarmed refusal returns before any event); "returns a URL and nothing else" is an instruction sentence plus the URL; "the one shape" undersells the no-block-at-all case.

On the hold — two notes for whoever lifts it:

  • Re-verify both shas against d2#2036's merged head before merging here. Its review asks are README/runbook prose, not meta() strings, so the shas should hold — but any later edit to a schema string in config.ts moves both pins and silently un-pairs the PRs.
  • For the record: the validator behaviour this bundle carries (optional client_id, redirect_uri, the two oauth_config rejections, the six new reserved keys) is already on d2 main and has been deploying since 09-03. What's genuinely gated on d2#2036 is prose agreement plus the new inert-block advisory. Nothing the skill tells a user to write would be rejected by today's deployed validator. The hold is still a fine call for keeping the two surfaces in step; just noting the gate is narrower than the body suggests.
  • The PR body attributes the 2.0.0→3.0.0 delta to two oauth_config rejections. That's accurate for what d2 chose to version, but relative to the previously vendored bundle the harness rubric also newly rejects six gateway.advanced keys, newly accepts three session_control shapes, and stops stripping token_endpoint_auth_method/omit_resource on round-trip. No fixture exercises any of that today (verified), so nothing flips — but a line listing the full delta would help a future bisect.

The "why the person is in the loop" paragraph is the best thing in this PR. It says the security property in plain words and then derives every constraint from it. Keep that.

- **No arguments, deliberately.** The agent cannot name a key, so it cannot choose the target. The confirm page enumerates what is actually live and unexpired in the caller's scope, and the person selects from that list.
- **The approver must be the same identity as the caller.** The browser login is matched against the identity the agent is calling with, so a person clears their own session state — not another user's. One login authorizes exactly one clear.
- **Markers and intent clear separately.** `clear_markers` offers every live `marker:` instance in scope, and flags one that is held by more than one writer — clearing a single holder leaves the marker standing, so each holder is acknowledged on its own. `clear_intent` only ever offers the platform-captured intent. An approved marker clear can never drop the intent, and the reverse holds too.
- **It is armed per gateway.** Clearing requires the `gateway.session_control` block in the gateway config, and that block should say so explicitly — `clearing: {enabled: true}` (see `dtwo-gateway-config`). Left implicit it arms only while intent capture is on and parks inert otherwise, which is the one shape that leaves marker policies running with no targeted way out of a marker. Where it is not configured the request comes back with a readable "not configured on this gateway" refusal that points at the TTL instead — handle that answer rather than assuming the flow is available. It fails closed throughout: nothing is ever half-cleared.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should-fix: "Where it is not configured the request comes back with a readable 'not configured on this gateway' refusal … handle that answer" is only reachable when the clear tool exists. The reserved server that hosts clear_markers is federated only when intent.enabled || clearingArmed (product repo: deploy.ts reservedServerFederated). On the markers-only gateway with no session_control block — or the parked empty block this same bullet describes — there is no clear tool in the agent's tool list, so there's nothing to request and no refusal ever appears. The refusal text (which does point at the TTL, verified) shows up on an intent-on gateway without a block, or after a runtime force-disarm.

Suggest: "Where it is not armed, the platform clear tool may be absent from your tool list entirely; where present but unarmed, the request returns a readable 'not configured on this gateway' refusal that points at the TTL. Handle both rather than assuming the flow is available."

Tiny wording nit on the same line: "the one shape that leaves marker policies running with no targeted way out" — the no-block-at-all gateway is the same situation and the more common one; "a shape" or "the shape to watch for" reads truer.


**Why the person is in the loop.** A marker is worth exactly as much as the agent's inability to remove it. If the agent a marker constrains could also lift it, the marker would constrain nothing — it would be a speed bump with a documented way around, and every policy built on markers would inherit that. So the human step is not a convenience tax on the flow; it **is** the control. What the ceremony produces is evidence: a named person, freshly authenticated at that moment, explicitly authorized *this* clear of *these* specific values. That is also why the flow declines to be convenient — no arguments, so the agent cannot choose the target; one interactive login per clear, so an approval cannot be batched, reused, or replayed; and the same identity as the caller, so it cannot be handed to whoever happens to be at the keyboard.

**What that means for how you use it.** Never offer a clear as a way around a policy decision. If a block is correct, the answer is to stop and explain it — not to reach for the clear. A clear is appropriate when the state has outlived its purpose: the condition that raised the marker has been dealt with, and **the person**, never the agent, judges that it has. An agent that reflexively requests a clear on every denial is doing the exact thing the human gate exists to prevent, and every request is recorded whether or not anyone approves it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: "every request is recorded whether or not anyone approves it" — true once clearing is armed (clear_minted at mint, clear_denied for too_many_pending_clears/unknown_operation, then clear_authorized / clear_committed). The unarmed refusal returns from the plugin before anything is minted and emits no event. "Once clearing is armed, every request is recorded …" keeps the sentence exact.


A marker has two exits: its TTL expires, or a **person approves a clear**. The second one is the fast path, and it is the one your deny reasons should offer first — a marker set at the start of an hour-long TTL can otherwise block someone for the rest of that hour over a condition they have already dealt with.

**An agent can request a clear; it cannot complete one.** Requesting returns a URL and nothing else. The person opens it, signs in interactively at the identity provider (a fresh login, even if they are already signed in), picks what to clear from a list the gateway builds, and confirms. Everything after the request happens in a browser, authenticated as a human.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: "Requesting returns a URL and nothing else" — the tool result is an instruction sentence plus the URL ("A human must approve this clear … Open the link below in a browser, sign in, and click Confirm — …\n\n"). The substance (a link, never a capability) is exactly right; "returns a link to open in a browser and nothing an agent can act on" would be both accurate and make the security point harder.

Comment thread dtwo/skills/dtwo-policy-rego/SKILL.md Outdated
- The walk-all-writers pattern (`some writer_uid; input.context.session.policies[writer_uid][key]`) is "present under *any* writer is truthy." To trust only a specific writer, filter on `writer_uid == "<known-uid>"`.
- **This pattern is for reading *marker* keys only.** Do **not** use it — or any direct `input.context.session.policies` read — to read the platform **intent** (see Intent-capture policies → Reading the session intent). "Present under any writer" is exactly wrong for intent: a tenant policy could stamp an intent-shaped value under its own writer slot and a walk-all-writers read would honour it, spoofing the session intent. Read intent only through the platform helper, which is pinned to the trusted intent-capture slot.
- Deny reasons are user-visible — explain what to do about the block (e.g. "wait for the marker TTL to expire"). Avoid "start a new session": marker state is scoped to tenant + user and survives reconnecting, so a new session for the same user won't clear it.
- **Deny reasons are user-visible — give the path out, fastest first.** State what happened and what the person can do about it. A marker has two exits, and they are not equal: a **human-approved clear** lifts it in under a minute, and **TTL expiry** lifts it eventually. Offer the clear first and keep TTL as the fallback — a reason that mentions only the TTL tells someone to wait an hour for something they could have resolved immediately. Phrase it so it reads correctly either way ("ask for a session clear … otherwise it lifts when the marker expires"), because clearing is armed per gateway: where it is not configured, the clear request returns a readable "not configured on this gateway" refusal and the TTL half of your sentence still holds. Word it as a recovery the person authorizes, not as a way around the decision — the clear needs their explicit approval in a browser precisely so an agent cannot use it to shrug off a block (see `dtwo-gateway-policy` → Clearing a marker for the mechanics and the reasoning).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth stating once, here or in the gateway-policy "Clearing a marker" section: "ask for a session clear" presumes the blocked person is working through an assistant that (a) has the platform clear tool federated, (b) will call it, and (c) relays the link. There's no human-initiated entry point — the only start is the MCP tool call, which needs the caller's gateway bearer — so a person reading this reason in a log or the Hub has nothing to click. That's the intended design (and matches the product repo's own wording), so not asking for a change to the sentence — just one clause so authors know who "ask" is addressed to, e.g. "ask your assistant to request a session clear".

Comment thread skill-harness/src/validatorBundle.ts Outdated
* with `EXPECTED_VALIDATOR_BUNDLE_VERSION` when the bundle is re-vendored.
*/
export const EXPECTED_VALIDATOR_BUNDLE_SHA256 = 'd5d07962a80a47e3ccc7a9550e6b56650321bed2d3a541d932c70f167d5c9bda';
export const EXPECTED_VALIDATOR_BUNDLE_SHA256 = '971e2d7ccb7a2e28f35a899b510697d73d0606a0083825af7feead3b0e47ee16';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified: rebuilt from the d2 PR head (esbuild 0.28.1, node 24, macOS-arm64) → 971e2d7c…ee16, cmp byte-identical to the vendored file; the schema artifact is byte-identical too and the .d.mts is unchanged vs main. One ask for whoever lifts the hold: re-check both shas against d2#2036's merged head first. Its open review asks are README/runbook prose (not meta() strings), so they shouldn't move — but any late edit to a schema description/rationale in config.ts changes both artifacts and silently un-pairs the two PRs.

// Ordered allowlist: a field newly added to the artifact is DROPPED here
// rather than appended blindly. That is safe only because the coverage check
// at the end of this script refuses to write the digest until the field is
// placed deliberately — if you hit that error, add the name here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirmed the claim in this comment: removing 'omit_resource' from fieldOrder and running --check fails with "Digest coverage check failed — 1 item(s) missing … target sotw.oauth_config.omit_resource … is not rendered in the digest" before anything is written. Good comment to have — hitting that gate really does read like a generator bug the first time.

Review asks from #40, verified against the gateway source.

- The "handle the not-configured refusal" advice only holds when the tool
  exists. The reserved server hosting clear_markers is federated only when
  intent capture is on or clearing is armed, so on the markers-only gateway
  with no (or a parked) session_control block — the very shape that bullet
  warns about — there is no clear tool in the list, no request, and no refusal.
  Both skills now say unavailable looks two ways and to handle each. The deny
  reason itself needed no change: "otherwise it lifts when the marker expires"
  stays true when no tool exists, which is worth keeping deliberate.

- The deny-reason example now addresses the ask to the reader's agent. There is
  no human-initiated entry point — the only start is the platform tool call —
  so someone reading the reason in a log or the Hub has nothing to click.
  "agent" not "assistant": the skills already use "the agent" nineteen times
  and would otherwise give one actor two names.

- Two precision fixes: every request is recorded once clearing is ARMED (the
  unarmed refusal returns before anything is minted, so it emits no event), and
  the request returns a link to open in a browser rather than "a URL and
  nothing else" — the result is an instruction plus the link, and naming what
  an agent cannot act on makes the security point harder.

- Arming guidance points at the explicit clearing.enabled, matching the
  regenerated config digest.

Verified: both Rego examples pass opa check and the registry example still
evaluates to the reworded reason (including the missing-entry default), digest
--check in sync, harness 177/177.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pareilly

pareilly commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Both asks and all three nits taken in e546e07, verified against the gateway source rather than accepted on description.

The tool-absent branch was the real find. You're right that the refusal is only reachable when the tool exists — the reserved server is federated only when intent capture is on or clearing is armed, so on the markers-only gateway with no (or parked) block there is nothing to call. Both skills now say unavailable looks two ways and to handle each. Worth noting the deny reason itself needed no change: "otherwise it lifts when the marker expires" stays true when no tool exists, so an author following the phrasing rule produces a correct message either way. That was luck rather than design, so I've kept the rule prominent.

On "who is ask addressed to": taken, and the example now says "ask your agent to request a session clear". Not "assistant" — the skills already use "the agent" nineteen times and zero "assistant", so introducing a second name for the same actor in a document about what the agent can and cannot do would cost more than the precision gained.

Your delta point turned out stronger than stated. I probed both vendored bundles directly instead of reasoning from the version bump: the old bundle rejects a session_control block with no client_id — the shape the skill now recommends — and rejects redirect_uri outright, while newly rejecting six env names inside gateway.advanced. So the stale pairing wasn't just documentation drift; the rubric would have scored the currently correct answer as invalid. The full delta is now in the body.

And you were right about the hold being narrower than I framed it. The validator behaviour here has been on the product repo's main and deploying since 09-03, so nothing this skill tells a customer to write would be rejected today; what's genuinely gated is the guidance rewrite and the advisory that gives it teeth. The body says that now, along with your re-verify-the-shas instruction for whoever lifts it. That also cuts the other way, which I've flagged internally: the stale skill is telling customers client_id is required right now, so most of this content is overdue rather than premature.

Digest --check in sync, harness 177/177, both Rego examples still pass opa check and the registry example still evaluates to the reworded reason including the missing-entry default.

@pareilly

pareilly commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Pairing check against the merged product-repo head — both files still paired.

The product-repo change this vendors is merged. Rebuilding on that merged head and comparing byte-for-byte against what's vendored here:

product repo vendored here
validator bundle 971e2d7c…ee16 971e2d7c…ee16 paired
schema artifact 7f65aa88…fd1a 7f65aa88…fd1a paired

Expected, since the review round there touched README, runbook, docs, tests and a workflow comment — not any schema meta() string. Recording it because that was the requested pre-merge check, and because nothing in this repo can detect drift on its own: the artifact records "source": "Dtwo gateway ConfigSchema", never a revision.

Still held until the release carrying that change reaches production. Two things to redo at that point rather than trusting this comment:

  1. Re-run the pairing check above against whatever is then on the product repo's main — further changes may land before the deploy, and any edit to a schema description or rationale moves both files and silently un-pairs them.
  2. If they have moved, re-vendor both together from one revision and bump all three pins — refreshing only one is the recorded validator-bundle-drift defect.

@oschaaf

oschaaf commented Sep 4, 2026

Copy link
Copy Markdown
Member

Friendly suggestion, not a review ask: give the new session_control guidance a fixture or two in skill-harness.

Otto raised a good question after the review — could the skill changes here be exercised by the harness, and does CI run it? Here's where things stand, so you can decide whether it's worth a few minutes on this PR or a follow-up.

What the harness can and can't reach in this PR

  • The harness benchmarks dtwo-gateway-config only (YAML generation → five deterministic rubrics). dtwo-gateway-policy and dtwo-policy-rego aren't referenced anywhere in it, so the "Clearing a marker" / registry / deny-reason prose has no harness coverage available. The review checked that prose against the gateway source instead, which is the only check we have for it right now.
  • The config-skill half is reachable, and it's the part the re-vendored 3.0.0 bundle changes: session_control without client_id, redirect_uri, and clearing: {enabled: true} all went from rejected → accepted. No fixture on main mentions session_control or clearing today, so nothing exercises the new "write true whenever you want clearing" guidance.

Two fixtures that would pin it (dropping into skill-harness/fixtures/, same shape as ssrf-allowed-networks-cidrs.yaml):

# session-control-markers-only.yaml
id: session-control-markers-only
tier: aspirational            # promote to required once it passes at 100%
tags: [session_control, clearing, markers_only]
user_prompt: |
  Our gateway uses Dtwo authentication (Auth0 tenant acme.us.auth0.com,
  audience https://api.acme.com). We run marker-writing policies but do
  NOT use intent capture. Enable human-approved clearing so a person can
  lift a marker without waiting for its TTL. One bearer-token MCP server
  at https://mcp.acme.com/mcp.
followups:
  - match: /audience/i
    reply: "https://api.acme.com"
expect:
  must_validate: true
  required_paths:
    - gateway.authentication.jwks_info.jwt_issuer
    - gateway.authentication.jwks_info.jwt_audience
    - gateway.session_control.clearing.enabled
    - mcp_servers[0].authentication.type
  forbidden_paths:
    - gateway.session_control.client_id     # Dtwo auth → platform-provisioned app
    - gateway.intent.enabled
  value_constraints:
    - { path: gateway.session_control.clearing.enabled, equals: true }
  semantic_rubric:
    - "States clearing.enabled: true explicitly rather than relying on an empty block (which parks inert with intent off)"
clarifying_question_expected: false
# session-control-intent-enabled.yaml
id: session-control-intent-enabled
tier: aspirational
tags: [session_control, clearing, intent]
user_prompt: |
  Dtwo-authenticated gateway (acme.us.auth0.com, audience
  https://api.acme.com) with intent capture turned on. Also arm
  human-approved session clearing. One bearer-token MCP server at
  https://mcp.acme.com/mcp.
followups:
  - match: /audience/i
    reply: "https://api.acme.com"
expect:
  must_validate: true
  required_paths:
    - gateway.intent.enabled
    - gateway.session_control
  forbidden_paths:
    - gateway.session_control.client_id
  value_constraints:
    - { path: gateway.intent.enabled, equals: true }
  semantic_rubric:
    - "Does not set clearing.enabled: false (rejected while intent capture is on)"
    - "Prefers clearing.enabled: true over an empty block, per the updated guidance"
clarifying_question_expected: false

The first one is the interesting case: it's exactly the "inert block" hazard d2#2036's advisory exists for, and must_validate + no_dropped_keys now genuinely exercise it against the new bundle (the old 2.0.0 bundle would have rejected it for the missing client_id).

How to run

cd skill-harness

# Offline layer — deterministic, no LLM, ~1s. Fixture parsing + rubric + digest drift.
pnpm test
#   (on pnpm 11 today, `pnpm install` trips on the package.json overrides — #36 fixes that;
#    until then: node --import tsx --test 'src/__tests__/**/*.test.ts')

# Sanity-check fixture selection without any model calls
pnpm bench --dry-run --prompts='session-control-*'

# Live bench — actually asks Claude, scores each sample. Uses your local claude login, no API key.
pnpm bench --provider=claude-cli --temperature=0 --samples=5 \
  --prompts='session-control-*' --output=./bench-results/session-control

results.md under --output is PR-comment ready if you want to paste the pass@k here. If the model doesn't yet follow the "write true" guidance reliably, the fixture stays aspirational with a known-defects.md entry — that's the documented convention, and it's still useful as a tripwire for the next re-vendor.

Does CI run it?

Not yet. The only checks on this repo today are GitHub's default CodeQL. Your #36 adds .github/workflows/skill-harness.yml, which runs the offline layer (tests, biome, tsc, digest --check) on every PR and push to main — the live bench stays local by design (model calls, ~15 min, quota). So: land #36 and the offline half of the above becomes a CI gate automatically; the live bench remains a run-it-yourself step before re-vendoring.

None of this gates the approval — happy for it to be a follow-up if you'd rather keep this PR to the docs.

pareilly and others added 6 commits September 8, 2026 14:47
Every marker example in the skill takes a capability away: something
happened, so something is now blocked. A marker can equally give one —
the tool is off by default and the marker turns it on, so the agent earns
access by doing the right thing first instead of holding a standing
privilege. "You may not comment on a ticket you have not read" is the
same shape as "you may not delete a file you have not copied", and an
author following this skill today has no guidance for either.

The mechanism is identical; four things change, and the first is a
security property rather than a style preference.

Pin the writer's UID. The walk-all-writers read this skill teaches is
correct for a deny marker — more emitters only make the gate more
cautious. Invert the marker and it inverts too: any policy able to write
that key now mints the grant. The skill already says session state is not
access-isolated; this is the consequence, and it was never drawn.

Read fail-closed. "Marker Rego gotchas" tells authors to structure gates
so the marker's presence denies, which is the deny direction. A grant
gate is the mirror image, so absent state must resolve to "not granted".
That gotcha now cross-references the new section instead of reading as
universal advice.

Scope to the resource via the value. A writableKeySchema enumerates key
names under additionalProperties:false, so a per-resource key is
impossible — the id has to ride in the value and be compared against the
argument of the call being judged.

Take the id from the response. tool_post_invoke carries {name, text} and
no arguments, which is the point rather than a limitation: the id comes
from what the upstream actually returned, so a caller cannot mark a
resource it never fetched and a failed call grants nothing.

Also adds the three things that bite when accumulating a set: a policy
cannot know its own UID until it exists (create with a placeholder, then
update — the same two-step the shipped intent-capture policies use);
writes apply after the decision, so a read sees only previous requests;
and the substrate caps (1 KB per value, 16 keys per write, 64 per
session), none of which appeared anywhere in the skill and all of which
silently bound a design.

Closes with what a grant gate is not: parallel calls are both evaluated
against earlier state, so both can be allowed before either write lands.
Recording "already used" makes the record correct, not the gate
exclusive — single-use for sequential calls, stated plainly rather than
implying exactly-once.

Both worked examples were composed into complete modules and validated;
the pattern is running on a live gateway (Jira read grants commenting on
that issue, per-issue, while the same read blocks outbound Slack).

No digest regeneration: generate-schema-digest.mjs targets
dtwo-gateway-config/SKILL.md, not this skill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review of the previous commit found one wrong claim and two style
breaks.

Wrong: "a read sees the state left by previous requests, never the write
this decision is about to make", and "a policy cannot count within a
single request". The first half is right, the framing is not. The
session-state ADR is explicit that a later evaluation in the SAME request
does see writes committed by an earlier one — an earlier pipeline step,
or ingress when the read happens on egress. That is exactly what makes
marker gating work, so stating the opposite would have steered an author
away from the ingress-marks / egress-reads pattern. The true restriction
is narrower: a policy cannot read a value it wrote in the same
evaluation.

Style: the examples were fragments while every other example in this
skill is a complete module (package, imports, default allow). An author
pasting a fragment gets a parse error. Both examples are now complete and
copy-pasteable, and both were run through validate-policy-rego.

Also replaces a real staging policy UID with the REPLACE-WITH-… form the
shipped intent-capture policies use. A live tenant identifier does not
belong in a customer-facing skill, and the placeholder teaches the
create-then-update two-step the surrounding text describes.

While confirming the cap wording, checked VALUE_TOO_LARGE against
_ON_DROP_HONORING_CODES: it is in the set, so the claim that onDrop
deny_request turns an oversized value into a denied call is correct.
Added the 16 KB per-session total alongside the other caps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'Marker constraints today' teaches the walk-all-writers read with no
mention that it is unsafe for a marker that grants a capability — the
same gap just closed in dtwo-policy-rego's fail-open gotcha. An author
reading only this skill would take any-writer as universal. One-line
cross-reference to the new section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skill showed multiple session_writes rules for the same key, never
two different keys from one policy — so the most interesting marker
pattern was the one it could not teach. Reading a confidential ticket
both proves you read it (so you may comment on it) and puts sensitive
content in the session (so you may not post it outbound): same fact, two
gates, pointing opposite ways.

Extends the accumulating writer with a second key conditional on response
content, and shows the writableKeySchema carrying BOTH entries — a write
to an undeclared key is dropped, so a schema listing only the first would
silently lose the second, which is exactly the kind of failure that is
hard to spot from the outside.

Two notes on the consequences rather than just the mechanics: the grant
half must be read with its writer pinned while the restriction half is
safe to read any-writer, so one writer feeds two readers using two
different idioms; and both keys count against the caps and carry
independent TTLs, so a restriction that expires before its grant leaves
the grant standing alone.

Also fixes a defect this addition exposed in the preceding example: it
bound _key directly from json.unmarshal(text).key and never bound the
response object, so the new _labels rule referenced an undefined _issue.
Rego fails silently, so an author following along would have got a marker
that never fires and no error. The base example now binds _issue and
derives _key from it, which is what the working policy does. Composed
module re-validated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewed the whole branch against main, not only the last commit. The
mechanics all check out against d2 and the fork (clear tool argument-less
and matched by full federated name; the unarmed refusal returns before any
mint so it emits no event; callback derived from the first jwt_audience;
clearing.enabled:false while intent is on rejected at parse; deploy fails
when clearing is armed with no ceremony client; registry entries omit
transitions when unrestricted; CGNAT block tagged v1.0.7.0; the config
digest regenerates byte-identical). What was left is prose.

- The deny-reason bullet fused two separate points with a "because" that
  did not follow: you address the agent because only the tool call starts
  a clear; the per-gateway arming is a separate reason to keep the TTL in
  the sentence. Split them.
- The preview snippet for pinned reads named the uid `_reviewer_uid`; the
  module it previews calls it `_writer_uid`. One name.
- The accumulating writer's comment said "same two-step as above" — the
  two-step is explained below it, and the module above uses a placeholder
  for the OTHER policy's uid, not a two-step.
- The fail-open gotcha referenced an `_active` helper no example defines
  (present on main; corrected while touching the line).
- The two-marker extension repeats the grant rule without saying so, which
  reads as a second write to the same key. Labelled it as the same rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vendored pair had drifted. Twelve commits landed upstream since the last
pairing check, two touching the schema surface: OAuth `scopes` went from
required to optional (schemaDefault `[]`, plus guidance for providers that
reject any scope parameter), and contextforge moved to 1.0.7.1.

That mattered for customers, not just for the pins: the vendored digest was
telling readers `scopes` is required for `type: oauth`, so anyone following
it would send a scope to a provider that rejects one.

- Both files re-vendored from one revision; the bundle's sha matches the
  product repo's committed sidecar byte for byte.
- Bundle version 3.0.0 -> 4.0.0, re-audited as the pin demands. 4.0.0 adds one
  rejection: a singular `scope` key on an oauth block whose `scopes` is
  absent or empty. Probed old against new: the shapes it newly rejects were
  ALREADY rejected under 3.0.0, for the different reason that `scopes` was
  then required — same verdict, different cause — and everything else is a
  relaxation. No fixture outcome moves; the fixtures that name
  `authentication.scopes` in required_paths all still set it.
- All three pins bumped together.
- The OAuth table gained a Default column: `scopes` now declares a default,
  and the coverage gate correctly refused to write a digest with nowhere to
  render it.
- Fixed a hand-written line in the generator that still said both oauth shapes
  require `scopes`. Generated tables cannot contradict the artifact, but that
  prose could, and the coverage gate does not read prose — the same trap that
  left the product repo's README asserting the opposite of its own CI job.

Verified: digest --check in sync, harness 177/177, no other skill claims
`scopes` is required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pareilly

Copy link
Copy Markdown
Contributor Author

Re-paired — and the earlier baseline had drifted, so this supersedes it.

Production is deploying, so I re-ran the pre-merge pairing check that comment asked for. It did not hold: twelve commits had landed upstream since, two touching the schema surface.

previously vendored now vendored
validator bundle 971e2d7c…ee16 (3.0.0) 91572745…12ede (4.0.0)
schema artifact 7f65aa88…fd1a 22f5fa77…b068

Both re-vendored from one revision; the bundle's sha matches the product repo's committed sidecar byte for byte, and all three pins moved together.

The drift was customer-facing, not just bookkeeping. OAuth scopes went from required to optional ([] default, with guidance for providers that reject any scope parameter). The digest as vendored was telling readers scopes is required for type: oauth — so anyone following it would send a scope to a provider that rejects one.

4.0.0 re-audit, as the version pin demands. It adds one rejection: a singular scope key on an oauth block whose scopes is absent or empty. Probing old against new, every shape 4.0.0 newly rejects was already rejected under 3.0.0 — for the different reason that scopes was then required. Same verdict, different cause, and everything else is a relaxation. No rubric result moves, and the fixtures naming authentication.scopes in required_paths all still set it.

Two side effects worth noting:

  • The OAuth table gained a Default column. scopes now declares a default and the coverage gate correctly refused to write a digest with nowhere to render it — the same guardrail firing as last time, working as intended.
  • A hand-written line in the generator still said both oauth shapes require scopes. Generated tables can't contradict the artifact, but that prose could, and the coverage gate doesn't read prose. Fixed.

Digest --check in sync, harness 177/177 against the new bundle. Ready to merge as soon as the deploy is confirmed.

@pareilly
pareilly merged commit a20f1a1 into main Sep 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants