Development evidence / local review

Independent perspectives.
Traceable decisions.

LIVE / Complete

Consensus is a proposal. Source citations are checked mechanically; behavior, research links and acceptance tests still need independent verification.

Coordinator outcome: 2 consensus proposals, 4 unresolved findings. This is a focused review of 2-4 selected files per project. Model proposals below are historical records, not implementation approvals. Independent checks corrected the FHIR audit claim and rejected serving cached bridge credentials after stream failure. Read the coordinator findings, tasks and priorities first. Engineering OS reused an identical previously reviewed packet; its RAG tests remain unrun.

Claude research > Codex challenge > Claude rebuttal > Kimi peer (when required) > Human selection

mcp-svid-auth

Review complete · 2 source files; 0 omitted

research: complete / verify: complete / rebuttal: complete / kimi: complete

Latest event: rebuttal / completed

Fail-closed replay cache is a shared, unbounded-by-client DoS surface with no way to size it

unresolved

Codex challenge: needs test

The global capacity check and default cache allocation support contention between subjects using one server instance. However, changing jti requires a newly valid signature, and assertion issuance capabilities and throughput are outside the snapshot. The roughly 30 requests/second exhaustion scenario is therefore unproven. The claim that sizing requires code changes is too broad: ReplayCache accepts max_entries and AuthzServer accepts replay_cache, although the supplied CLI exposes neither. Validate exhaustion using realistically obtainable assertions and verify another subject's rejection. Per-sub caps reduce individual impact but do not guarantee availability when the global cap fills.

Claude response: needs test

The structural half of F1 is confirmed by the snapshot: one process-wide ReplayCache (authz.py:118) capped at 10_000 (authz.py:46), and a full cache returns temporarily_unavailable/503 to every subsequent assertion regardless of subject (authz.py:95-101), with entries held until exp + skew (authz.py:257). So availability is coupled across all allowlisted clients in reject mode. The exploitability claim is unproven. Filling the table needs ~10k distinct validly signed assertions, and the snapshot's own comment notes SVID sources that cache and re-serve a single SVID (authz.py:47-48), which in reject mode would be rejected as replay rather than adding entries; assertion minting rate lives in omitted spiffe_keys/SPIRE behaviour, so the ~30 requests/second figure is arithmetic, not a measured or source-supported capability. Also too broad: ReplayCache takes max_entries (authz.py:66) and AuthzServer takes replay_cache, so embedders can size it; only build_parser/main expose no flag. Test: fill the cache from one subject and assert a second subject still gets a token, using assertions obtainable from the real SVID source, before accepting priority 3. Note a per-sub cap limits one subject's share but does not restore availability once the global cap fills.

Kimi independent peer: needs test

The structural claim is confirmed: a single per-process ReplayCache (authz.py:118) capped at 10,000 (authz.py:46) returns 503 to all subjects when full (authz.py:95-101), with entries held until exp + _CLOCK_SKEW (authz.py:257), and no CLI flag exists to size it (authz.py:390-439, 453-461). However, exploitability is unproven: filling the cache requires ~10k distinct validly signed assertions, and the snapshot notes some SVID sources cache and re-serve one SVID (authz.py:47-48), which would be replay-rejected rather than adding entries. The claim that sizing requires code changes is overbroad since max_entries and replay_cache are constructor parameters. Test with real SVID source behavior before accepting priority 3.

Research claim and proposal

ReplayCache is a single process-wide table capped at DEFAULT_REPLAY_CACHE_SIZE = 10_000 (authz.py:46), and when it is full after evicting expired entries it raises temporarily_unavailable/503 for every further assertion (authz.py:95-101), as the docstring states (authz.py:63). Entries are only evicted once exp + _CLOCK_SKEW has passed (authz.py:257), so with the default max_svid_lifetime of 300s plus 30s skew a single allowlisted client that mints fresh jti values can pin ~10k live entries with roughly 30 requests/second and thereby deny token issuance to all other clients until its assertions age out. There is no per-sub or per-trust-domain bound, and build_parser (authz.py:390-439) exposes no flag for ReplayCache.max_entries, while main() (authz.py:453-461) never overrides the default, so an operator cannot raise or partition the limit without code changes. Replay protection itself is correct; the gap is blast radius and operability of the fail-closed path.

Bound the cache per subject instead of only globally, and make the limit configurable. Concretely: track a per-sub live count in ReplayCache.add and reject that sub once it exceeds a per-client cap, so one client's burst cannot exhaust capacity shared with others; keep the global cap as a backstop. Add a --replay-cache-size (and optional --replay-cache-per-client) CLI flag in build_parser and wire it through main() into ReplayCache(max_entries=...). Also emit the global-full condition as a distinct audit decision so operators can alert on it rather than inferring it from 503 counts, and document in the fail-closed docstring that capacity must be sized as max_svid_lifetime times the expected assertion rate.

hipaa-fhir-mcp

Review complete · 3 source files; 0 omitted

research: complete / verify: complete / rebuttal: complete / kimi: complete

Latest event: rebuttal / completed

Config schema has no production guard: PHI can default to a public sandbox FHIR server over cleartext http

unresolved

Codex challenge: needs test

The schema visibly defaults FHIR_BASE_URL and leaves token and audit-file settings optional without NODE_ENV-specific checks. However, the default is HTTPS, and no supplied consumer shows requests, PHI handling, audit routing, or production startup. Acceptance of an HTTP URL should be tested with the pinned Zod version. Requiring a token and file sink also needs application requirements: these files do not establish that those are the only supported authentication and auditing mechanisms. The configuration concern is supported; the claimed operational exposure is unproven.

Claude response: needs test

Split evidence. Proven from the snapshot: no cross-field or NODE_ENV-keyed refinement exists, FHIR_BASE_URL silently defaults to https://r4.smarthealthit.org, and FHIR_AUTH_TOKEN and AUDIT_LOG_FILE are optional in production as well as development, so a production parse of an env containing only a 32-char AUDIT_HMAC_KEY will succeed. Not proven: (a) the title's cleartext-http exposure, since the shipped default is https and http acceptance rests on z.string().url() behaviour in an unpinned zod version absent from the packet; (b) that a defaulted base URL causes PHI to reach a third party, since no FHIR client, tool registration or entrypoint is supplied; (c) that an unset AUDIT_LOG_FILE leaves no audit sink, since the logger is not supplied and stderr or another sink may be the fallback; (d) that token-based auth is the only supported credential path. The remediation and acceptance tests in F1 are appropriate and would settle (a) and the production-parse behaviour without needing the absent files. I do not infer any absent feature from the omitted files.

Kimi independent peer: needs test

The snapshot proves the structural half: no cross-field or NODE_ENV-keyed refinement exists (lines 3-16), FHIR_BASE_URL silently defaults to https://r4.smarthealthit.org (line 4), and FHIR_AUTH_TOKEN/AUDIT_LOG_FILE are optional everywhere (lines 5,7). A production parse with only AUDIT_HMAC_KEY present succeeds. The impact half is unproven: (a) the title's 'cleartext http' claim is unsupported because the built-in default is https, and z.string().url() scheme acceptance for http depends on an unpinned zod version absent from the packet; (b) no supplied file shows a FHIR client, PHI flow, or that a third party receives data; (c) no audit logger is supplied, so 'missing audit sink' is speculative; (d) no entrypoint is present that might apply production checks above loadConfig. The proposed acceptance tests in F1 would correctly settle the proven behaviour and the http-acceptance question.

Research claim and proposal

src/config.ts validates every variable independently, with no cross-field refinement keyed on NODE_ENV. Consequences visible in the snapshot: (1) NODE_ENV=production parses cleanly while FHIR_BASE_URL falls back to the public SMART sandbox https://r4.smarthealthit.org (line 4), so a deploy that forgets the variable sends requests to a third-party host instead of refusing to start; (2) z.string().url() accepts http:// and other schemes, so PHI-bearing traffic over cleartext passes validation; (3) AUDIT_LOG_FILE (line 7) and FHIR_AUTH_TOKEN (line 5) are optional in all environments, so a production process can start with no configured audit sink and no credential. For a server whose stated purpose is HIPAA-scoped FHIR access, this configuration path fails open toward an external endpoint rather than failing closed. I make no claim about what the audit logger or FHIR client do with these values; those files are not in the snapshot.

Add a superRefine (or .refine) on ConfigSchema that activates when NODE_ENV === "production" and requires: FHIR_BASE_URL explicitly present in env rather than defaulted; its parsed protocol to equal "https:"; AUDIT_LOG_FILE non-empty; FHIR_AUTH_TOKEN non-empty. To distinguish present-from-defaulted, drop .default() on FHIR_BASE_URL and apply the sandbox fallback only when NODE_ENV is development or test. Report all violations through the existing flatten().fieldErrors path so the stderr message lists each missing variable. Independently, consider enforcing an https-only URL in every environment unless the host is localhost, and hoist process.exit(1) out of loadConfig into the entrypoint so the invalid-config branch is unit-testable.

mcp-spiffe-bridge

Review complete · 2 source files; 0 omitted

research: complete / verify: complete / rebuttal: complete / kimi: complete

Latest event: rebuttal / completed

FetchX509SVID stream never reconnects; one transient failure latches the bridge into permanent 503

consensus proposal

Codex challenge: support

Once run() rejects, its catch sets streamFailure and clears cachedSvid. getX509Svid() subsequently throws, and start() cannot restart because streamTask remains assigned. An unclosed clean stream end also rejects. The transport converts these request failures into 503 responses. This establishes the terminal state without runtime testing. However, agent restart and socket-drop behavior depend on the unavailable spiffe client implementation. The suggested cache retention and invalid-update skipping require separate security justification; they do not follow from the demonstrated reconnect defect.

Claude response: support

Supported on the mechanism, with scope caveats. start() returns early whenever streamTask is already assigned, and the single catch on run() sets streamFailure and nulls cachedSvid; no code path clears streamFailure, re-arms firstMessageDeferred, or reassigns streamTask. getX509Svid() therefore throws permanently once run() rejects, including after a clean stream end while not closed (line 123). The transport makes this user-visible: handleRequest's first statement awaits getX509Svid(), and the createServer wrapper converts the rejection into a 503. This is a latched-availability defect readable from the snapshot alone. Not established by the snapshot: that transient agent restarts or socket drops actually surface as rejections on call.responses (the spiffe client was not supplied), and that the suggested recovery semantics are correct. Retaining a cached SVID through a stream outage and treating an out-of-validity response as skippable are fail-open changes to credential handling; they need their own justification and should not be adopted as a side effect of fixing reconnect. Priority 3 is reasonable for an availability defect requiring process restart.

Kimi independent peer: support

The core latching defect is established from the snapshot: streamTask assignment at line 55 blocks restart via start()'s guard at line 54; the catch sets streamFailure and nulls cachedSvid with no clearing path; getX509Svid() throws permanently at line 66. Clean stream end at line 123 also rejects when not closed. Transport handleRequest awaits getX509Svid() first, converting to 503. However, the finding assumes without evidence that agent restarts and socket drops surface as rejections on call.responses (external spiffe client not supplied). The proposed cache-retention and invalid-response-skipping remedies are fail-open credential handling choices whose safety is unproven and should not ride along with the reconnect fix. The acceptance criterion presupposes these contested semantics.

Research claim and proposal

WorkloadApiSvidFetcher consumes the FetchX509SVID stream exactly once, with no retry or backoff. start() assigns streamTask = this.run().catch(...), and that single catch sets this.streamFailure and sets this.cachedSvid = null. run() also throws on a clean stream end (line 123) and on any response whose leaf is outside its validity window (line 111), so an agent restart, a socket drop, or one momentarily not-yet-valid SVID all land in the same terminal state. After that, getX509Svid() short-circuits at line 66 forever, because nothing clears streamFailure or re-arms streamTask or firstMessageDeferred. MtlsTransportServer.handleRequest awaits this.opts.fetcher.getX509Svid() as its first statement (line 134), so every subsequent request throws and is answered 503 by the createServer catch handler. A still-valid cached SVID is discarded rather than used for graceful degradation, so the process must be restarted to recover.

Make run() a supervised reconnect loop instead of a one-shot task: wrap its body in a while (!this.closed) loop that opens a fresh fetchX509SVID call each iteration, catches per-iteration errors, and sleeps with jittered exponential backoff (for example 100ms up to a 30s cap) before retrying. On each error set a transient field such as lastStreamError, used only to reject a still-pending firstMessageDeferred, then re-arm this.firstMessageDeferred = deferred() so later callers await the next connect instead of inheriting a settled rejection. Do not null cachedSvid on stream error; keep serving it and let the existing validity-window check at lines 68-71 fail closed once it actually expires. Treat an invalid or not-yet-valid response as a skipped message (continue) rather than a stream-killing throw at line 111. Keep the permanent-failure path only for close().

ecs-contract

Review complete · 3 source files; 0 omitted

research: complete / verify: complete / rebuttal: complete / kimi: complete

Latest event: rebuttal / completed

Unreadable-secret probe collapses every non-denial ClientError into a false "no key" refusal

consensus proposal

Codex challenge: support

The non-denial ClientError branch returns False, and pointer() converts False into a missing-key problem. That conflates an unsuccessful read with verified key absence. However, specific AWS error examples and complete CLI behavior remain untested, and error_code() is not supplied. The proposed automatic continuation is not necessary to fix the misleading diagnostic: operational errors could instead remain blocking with an accurate message. The acceptance requirement that CLI render exit 0 also depends on other preparation steps succeeding.

Claude response: support

Supported on the diagnostic defect, which is provable from the snapshot alone and does not depend on any particular AWS error code existing: any ClientError whose code is not in _DENIED reaches `return False` (sources.py:151), and pointer() reads False as proof of absence and emits a hard problem (sources.py:128), which drives REFUSED in the render/drift path (cli.py:257). The asymmetry the claim rests on is explicit in the text: BotoCoreError on the same get_secret_value call maps to None (sources.py:153) and so only warns. Two qualifications. First, the remedy is a policy choice the snapshot does not settle: returning None so the binding is kept is reasonable, but so is refusing with a message that says the read failed rather than that the key is missing; the finding should be scoped to the misleading diagnostic, not to mandatory continuation. Second, the acceptance criterion that CLI render exits 0 under a throttling stub bakes in that policy and also depends on contract check, live() and render() succeeding, none of which are supplied. Narrow acceptance to the resolve() layer: a stubbed client raising a non-denial ClientError must not produce a problem whose message claims the key is absent.

Kimi independent peer: support

The diagnostic defect is fully visible in the snapshot: any ClientError with code not in _DENIED falls through to `return False` (sources.py:151), while BotoCoreError on the same call returns None (sources.py:153). pointer() converts False into the assertion `the secret behind this pointer has no key {json_key}` (sources.py:128), which populates Resolution.problems and drives REFUSED in render/drift (cli.py:257). This conflates unreadable secrets with verified key absence. Two qualifications: (1) the remedy is a policy choice not settled by the snapshot-keeping refusal with an accurate read-failure message is equally valid, so the finding should be scoped to the misleading diagnostic, not mandatory continuation; (2) the acceptance criterion requiring CLI render exit 0 under throttling overreaches by embedding policy and depending on unsupplied contract/render/aws modules. Narrow acceptance to resolve() layer: a non-denial ClientError must not produce a problem claiming the key is absent.

Research claim and proposal

In sources.py, _has_json_key distinguishes only two AWS failure classes: error codes in _DENIED return None (treated as "cannot confirm", binding kept with a warning), and BotoCoreError returns None. Every other ClientError returns False (sources.py:151). The caller treats False as proof of absence and emits a hard problem, "the secret behind this pointer has no key <k>" (sources.py:128), which makes resolve() report problems and the CLI return REFUSED (cli.py:257). So a throttling error, a KMS decryption failure, a secret pending deletion, or an InvalidRequest on get_secret_value all become a confident, wrong statement about the secret's contents and block a deploy, while a network-level failure of the same call only warns. The asymmetry is in the code itself: BotoCoreError is explicitly mapped to None one line below the blanket False.

Make _has_json_key return False only when the secret was actually read and the key is absent. Narrow the False branch to the codes that genuinely mean "readable but not there" (ResourceNotFoundException for the secret id, and the existing json/dict checks), and return None for every other ClientError so the caller warns and keeps the binding, consistent with the BotoCoreError branch. If a distinct third state is wanted, return an enum rather than bool | None so the "could not determine" case cannot be silently confused with "absent". Consider making the warning text name the failure class so an operator can tell throttling from permissions.

ciso-assistance

Review complete · 2 source files; 0 omitted

research: complete / verify: complete / rebuttal: complete / kimi: complete

Latest event: rebuttal / completed

Lab-import evidence digest is non-canonical and not bound to received bytes

unresolved

Codex challenge: dispute

The hash covers a re-serialized report, so key order can affect it. However, non-canonical hashing alone does not invalidate a digest, and the claim that it cannot equal any file the submitter holds is unsupported: a file containing that serialization would match. The export says 'Imported record SHA-256', not original file hash. Also, do_POST rejects Content-Length above MAX_BYTES before reading JSON, contradicting the alleged missing submitted-payload bound. No supplied contract requires canonical hashes; assess() using canonicalization does not establish such a requirement for lab imports.

Claude response: support

Supported on mechanism, at reduced severity. server.py:280 hashes json.dumps(raw) of the parsed object with default separators and ensure_ascii, and core.py:326 stores that as artifact_sha256; json.loads preserves key order into a dict and json.dumps without sort_keys preserves it, so the digest varies across submissions differing only in key order. core.py:207 shows canonical hashing was the deliberate choice for evidence, so the lab path is inconsistent with the module's own convention, and the exported value at core.py:393 sits under a technical-evidence heading whose limits line calls it an imported artifact. I accept the verifier's corrections: this is not an integrity bypass, a file containing that exact serialization would match, and server.py:226 already bounds the submitted body, so F1's size point is not a missing guard and only notes that core.py:286 measures a different byte string. Scope the fix to canonical serialization plus a precise export label; priority 1-2, not 3.

Kimi independent peer: support

The core mechanism is correct: server.py:280 passes json.dumps(raw).encode() with default settings (no sort_keys, default separators, ensure_ascii=True), and core.py:326 hashes that re-serialization. Since json.loads preserves key order and json.dumps without sort_keys preserves it, artifact_sha256 varies with input key order. This is inconsistent with core.py:207 where canonicalization with sort_keys and compact separators is deliberate. The export at core.py:393 labels this as 'Imported record SHA-256' under a 'Linked technical evidence' heading, implying verifiability that is not robustly reproducible. However, I dispute two sub-claims: (1) 'cannot equal any file the submitter holds' is false-a file containing that exact serialization would match; (2) 'MAX_BYTES guard measures re-encoded object rather than submitted payload' is technically true at core.py:286 but irrelevant because server.py:226 already bounds the raw request body. The finding should be scoped to reproducibility inconsistency and labeling, not integrity bypass or missing size guard. Priority 2, not 3.

Research claim and proposal

import_lab() receives its "artifact" as json.dumps(raw) of the already-parsed request object (server.py:280) and hashes it directly (core.py:326). Python preserves JSON input key order, and json.dumps defaults to ", "/": " separators and ensure_ascii escaping, so artifact_sha256 varies with incoming key order and does not equal the digest of any file the submitter holds. The exported report publishes this value as "Imported record SHA-256" (core.py:393) in a section headed as technical evidence, implying a verifiable binding that does not exist. This is inconsistent with assess(), which canonicalizes evidence with sort_keys=True and compact separators before hashing (core.py:207), showing the canonical form was deliberate there. The MAX_BYTES guard (core.py:286) also measures the re-encoded object rather than the submitted payload, so it is not a faithful bound on what was received.

Make the digest reproducible and honestly labelled. Minimal change: at the call site pass json.dumps(raw, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() so it matches the canonical form already used in assess(), and change the export label to name what was hashed, e.g. "Canonical lab JSON SHA-256". Stronger option: have /api/import-lab retain the raw request body bytes and hash the submitted bytes for the lab document, so the value can be recomputed by the submitter from their own file. Either way, enforce the size bound on the submitted payload before re-encoding (do_POST already bounds the whole body via Content-Length; apply or document that bound instead of measuring the re-encoded object).

engineering-os

Review complete · 4 source files; 0 omitted

research: complete / verify: complete / rebuttal: complete / kimi: complete

No tracker adapter seam: beads is baked into the domain types, loader, and single-source store

unresolved

Codex challenge: needs test

The supplied Source union and direct bd invocation establish loader coupling, and each IssueStore exposes one source status. However, Dependency.type and issue status are unrestricted strings, so another tracker need not widen those types or fake beads vocabulary. One source per store does not prove that the omitted application cannot aggregate stores. Issue lacks explicit repository attribution, but actual cross-repository merging requires grouping and configuration code absent here. Splitting an ID at its last hyphen is demonstrated; calling that split incorrect requires an identifier contract. Linear is explicitly optional roadmap work. An adapter seam is a reasonable improvement, but the claimed integration failures need a second-adapter exercise and multi-repository tests.

Claude response: support

The load-bearing claim is absence of an adapter seam, which is directly readable in the snapshot rather than a behavioral prediction. Source is a closed union of two beads-native shapes, loadIssues execs the bd binary in the same shared module, types.ts documents the model as the bd export shape, and IssueStore takes one Source and returns a fixed one-element status array with literal id and label, so a second tracker or repository cannot be registered without editing the shared loader and store. I do not adopt the verifier's blanket needs_test: showing that no interface exists needs no second-adapter exercise. I preserve two disagreements with F1 as written. First, Dependency.type is plain string, so another tracker need not emit beads vocabulary or widen the core type; only a comment is beads-specific, which weakens that leg. Second, calling the last-hyphen cut incorrect assumes an id format contract absent here, and cross-repository project collision depends on omitted composition and grouping code, so that consequence is untested. The attribution gap is still real at type level: Issue has no source or repository field (types.ts 13-34), so merged issues from distinct workspaces are indistinguishable however the omitted layer composes stores. Priority 3 fits design debt against the documented optional Linear adapter, not a present functional defect. The improvement and acceptance criteria stand, with item 3 read as requiring explicit attribution rather than proving a current merge bug.

Kimi independent peer: needs test

The adapter seam absence is structurally verifiable: Source is a closed union of beads-native shapes, loadIssues execs bd inline, types.ts documents the model as bd export reduction, and IssueStore carries one Source with hardcoded status literals. I support this leg. However, F1 bundles unproven behavioral claims: (1) that last-hyphen splitting is incorrect requires an id format contract not in the snapshot; (2) that identical prefixes across workspaces cause merging requires omitted grouping/composition code to demonstrate; (3) that Dependency.type forces beads vocabulary is overstated since it is plain string. The attribution gap (no source/repo on Issue) is type-level real but its consequences need the omitted application layer. Priority 3 design debt is appropriate. Verdict is needs_test because the improvement criteria require behavioral verification (items 3-5) that cannot be validated from this selection alone.

Research claim and proposal

The dashboard has no adapter contract separating tracker access from the work-graph model, so the documented optional Linear adapter has nowhere to plug in, and multi-repository identity is unrepresentable. Three concrete couplings: (1) `Source` enumerates only beads-native shapes (a bd workspace root, a beads JSONL file) and `loadIssues` execs the `bd` binary directly, so a second tracker requires editing the shared loader rather than adding a module; (2) types.ts declares `Issue`/`Dependency` as a reduction of `bd export`, with `Dependency.type` documented as the beads vocabulary, so any other tracker must either fake beads strings or widen the core type; (3) `IssueStore` holds a single `Source` and returns a fixed one-element array with hardcoded id "source-1" and label "Configured tracker", so the status API cannot express more than one repository or tracker at all. Compounding this, `Issue` has no source or repository field and `project` is derived only from the id prefix before the last hyphen, so identical prefixes in two different workspaces merge into one project and ids whose trailing segment contains a hyphen are cut in the wrong place.

Introduce an explicit adapter boundary and give issues real identity. (a) Add a module, e.g. dashboard/src/tracker.ts, defining `interface TrackerAdapter { readonly id: string; readonly label: string; readonly kind: string; listIssues(): Promise<Issue[]>; }`, and make the current beads.ts the first implementation (BeadsAdapter over {root}, JsonlAdapter over {path}); keep the `bd` execFile call and the .beads existence check inside that module only. (b) In types.ts, drop the beads-specific framing: add required `source_id: string` and `repo: string` to `Issue`, and split `Dependency.type` into a normalized `kind` union plus a `raw_type` passthrough so non-beads trackers map into the model instead of emitting beads strings. (c) Change `IssueStore` to hold `readonly adapters: TrackerAdapter[]`, keep per-adapter loadedAt/error/snapshot state, and build sourceStatus() by mapping over the adapters using each adapter's own id/label/kind instead of the literals "source-1" and "Configured tracker". (d) Key project identity on source_id plus projectOf(id), or have the adapter supply the project explicitly, so same-prefix ids from different repositories stay distinct.